Director.js

import Logger from './Logger'
import Diagnostics from './utils/Diagnostics'
import FetchError from './utils/FetchError'

const logger = Logger.get('Director')
const streamTypes = {
  WEBRTC: 'WebRtc',
  RTMP: 'Rtmp'
}

let liveWebsocketDomain = ''
export const defaultApiEndpoint = 'https://director.millicast.com'
let apiEndpoint = defaultApiEndpoint

/**
 * @module Director
 * @description Simplify API calls to find the best server and region to publish and subscribe to.
 * For security reasons all calls will return a [JWT](https://jwt.io) token for authentication including the required
 * socket path to connect with.
 *
 * You will need your own Publishing token and Stream name, please refer to [Managing Your Tokens](https://docs.dolby.io/streaming-apis/docs/managing-your-tokens).
 */

/**
 * @typedef {Object} DRMObject
 * @property {String} fairPlayCertUrl - URL of the FairPlay certificate server.
 * @property {String} fairPlayUrl - URL of the FairPlay license server.
 * @property {String} widevineUrl - URL of the Widevine license server.
 */

/**
 * @typedef {Object} MillicastDirectorResponse
 * @global
 * @property {Array<String>} urls - WebSocket available URLs.
 * @property {String} jwt - Access token for signaling initialization.
 * @property {Array<RTCIceServer>} iceServers - Object which represents a list of Ice servers.
 * @property {DRMObject} [drmObject] - DRM proxy server information.
 */

/**
 * @typedef {Object} DirectorPublisherOptions
 * @global
 * @property {String} token - Millicast Publishing Token.
 * @property {String} streamName - Millicast Stream Name.
 * @property {("WebRtc" | "Rtmp")} [streamType] - Millicast Stream Type.
 */

/**
 * @typedef {Object} DirectorSubscriberOptions
 * @global
 * @property {String} streamName - Millicast publisher Stream Name.
 * @property {String} streamAccountId - Millicast Account ID.
 * @property {String} [subscriberToken] - Token to subscribe to secure streams. If you are subscribing to an unsecure stream, you can omit this param.
 */

const Director = {
  /**
   * @function
   * @name setEndpoint
   * @description Set Director API endpoint where requests will be sent.
   * @param {String} url - New Director API endpoint
   * @returns {void}
   */
  setEndpoint: (url) => {
    apiEndpoint = url.replace(/\/$/, '')
  },

  /**
   * @function
   * @name getEndpoint
   * @description Get current Director API endpoint where requests will be sent. Default endpoint is 'https://director.millicast.com'.
   * @returns {String} API base url
   */
  getEndpoint: () => {
    return apiEndpoint
  },

  /**
   * @function
   * @name setLiveDomain
   * @description Set Websocket Live domain from Director API response.
   * If it is set to empty, it will not parse the response.
   * @param {String} domain - New Websocket Live domain
   * @returns {void}
  */
  setLiveDomain: (domain) => {
    liveWebsocketDomain = domain.replace(/\/$/, '')
  },

  /**
   * @function
   * @name getLiveDomain
   * @description Get current Websocket Live domain.
   * By default is empty which corresponds to not parse the Director response.
   * @returns {String} Websocket Live domain
  */
  getLiveDomain: () => {
    return liveWebsocketDomain
  },

  /**
   * @function
   * @name getPublisher
   * @description Get publisher connection data.
   * @param {DirectorPublisherOptions} options - Millicast options.
   * @returns {Promise<MillicastDirectorResponse>} Promise object which represents the result of getting the publishing connection path.
   * @example const response = await Director.getPublisher(options)
   * @example
   * import { Publish, Director } from '@millicast/sdk'
   *
   * //Define getPublisher as callback for Publish
   * const streamName = "My Millicast Stream Name"
   * const token = "My Millicast publishing token"
   * const tokenGenerator = () => Director.getPublisher({token, streamName})
   *
   * //Create a new instance
   * const millicastPublish = new Publish(streamName, tokenGenerator)
   *
   * //Get MediaStream
   * const mediaStream = getYourMediaStreamImplementation()
   *
   * //Options
   * const broadcastOptions = {
   *    mediaStream: mediaStream
   *  }
   *
   * //Start broadcast
   * await millicastPublish.connect(broadcastOptions)
   */
  getPublisher: async (options, streamName = null, streamType = streamTypes.WEBRTC) => {
    const optionsParsed = getPublisherOptions(options, streamName, streamType)
    logger.info('Getting publisher connection path for stream name: ', optionsParsed.streamName)
    const payload = { streamName: optionsParsed.streamName, streamType: optionsParsed.streamType }
    const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${optionsParsed.token}` }
    const url = `${Director.getEndpoint()}/api/director/publish`
    try {
      const response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(payload) })
      let data = await response.json()
      if (data.status === 'fail') {
        const error = new FetchError(data.data.message, response.status)
        throw error
      }
      data = parseIncomingDirectorResponse(data)
      logger.debug('Getting publisher response: ', data)
      Diagnostics.initAccountId(data.data.streamAccountId)

      return data.data
    } catch (e) {
      logger.error('Error while getting publisher connection path. ', e)
      throw e
    }
  },

  /**
   * @function
   * @name getSubscriber
   * @description Get subscriber connection data.
   * @param {DirectorSubscriberOptions} options - Millicast options.
   * @returns {Promise<MillicastDirectorResponse>} Promise object which represents the result of getting the subscribe connection data.
   * @example const response = await Director.getSubscriber(options)
   * @example
   * import { View, Director } from '@millicast/sdk'
   *
   * //Define getSubscriber as callback for Subscribe
   * const streamName = "My Millicast Stream Name"
   * const accountId = "Millicast Publisher account Id"
   * const tokenGenerator = () => Director.getSubscriber({streamName, accountId})
   * //... or for an secure stream
   * const tokenGenerator = () => Director.getSubscriber({streamName, accountId, subscriberToken: '176949b9e57de248d37edcff1689a84a047370ddc3f0dd960939ad1021e0b744'})
   *
   * //Create a new instance
   * const millicastView = new View(streamName, tokenGenerator)
   *
   * //Set track event handler to receive streams from Publisher.
   * millicastView.on('track', (event) => {
   *   addStreamToYourVideoTag(event.streams[0])
   * })
   *
   * //View Options
   * const options = {
   *  }
   *
   * //Start connection to broadcast
   * await millicastView.connect(options)
   */

  getSubscriber: async (options, streamAccountId = null, subscriberToken = null) => {
    const optionsParsed = getSubscriberOptions(options, streamAccountId, subscriberToken)
    Diagnostics.initAccountId(optionsParsed.streamAccountId)
    logger.info(`Getting subscriber connection data for stream name: ${optionsParsed.streamName} and account id: ${optionsParsed.streamAccountId}`)

    const payload = { streamAccountId: optionsParsed.streamAccountId, streamName: optionsParsed.streamName }
    let headers = { 'Content-Type': 'application/json' }
    if (optionsParsed.subscriberToken) {
      headers = { ...headers, Authorization: `Bearer ${optionsParsed.subscriberToken}` }
    }
    const url = `${Director.getEndpoint()}/api/director/subscribe`
    try {
      const response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(payload) })
      let data = await response.json()
      if (data.status === 'fail') {
        const error = new FetchError(data.data.message, response.status)
        throw error
      }
      data = parseIncomingDirectorResponse(data)
      logger.debug('Getting subscriber response: ', data)
      if (optionsParsed.subscriberToken) data.data.subscriberToken = optionsParsed.subscriberToken
      return data.data
    } catch (e) {
      logger.error('Error while getting subscriber connection path. ', e)
      throw e
    }
  }
}

const getPublisherOptions = (options, legacyStreamName, legacyStreamType) => {
  let parsedOptions = (typeof options === 'object') ? options : {}
  if (Object.keys(parsedOptions).length === 0) {
    parsedOptions = {
      token: options,
      streamName: legacyStreamName,
      streamType: legacyStreamType
    }
  }
  return parsedOptions
}

const getSubscriberOptions = (options, legacyStreamAccountId, legacySubscriberToken) => {
  let parsedOptions = (typeof options === 'object') ? options : {}
  if (Object.keys(parsedOptions).length === 0) {
    parsedOptions = {
      streamName: options,
      streamAccountId: legacyStreamAccountId,
      subscriberToken: legacySubscriberToken
    }
  }
  return parsedOptions
}

const parseIncomingDirectorResponse = (directorResponse) => {
  if (Director.getLiveDomain()) {
    const domainRegex = /\/\/(.*?)\//
    const urlsParsed = directorResponse.data.urls.map(url => {
      const matched = domainRegex.exec(url)
      return url.replace(matched[1], Director.getLiveDomain())
    })
    directorResponse.data.urls = urlsParsed
  }
  // TODO: remove this when server returns full path of DRM license server URLs
  if (directorResponse.data.drmObject) {
    const playReadyUrl = directorResponse.data.drmObject.playReadyUrl
    if (playReadyUrl) {
      directorResponse.data.drmObject.playReadyUrl = `${Director.getEndpoint()}${playReadyUrl}`
    }
    const widevineUrl = directorResponse.data.drmObject.widevineUrl
    if (widevineUrl) {
      directorResponse.data.drmObject.widevineUrl = `${Director.getEndpoint()}${widevineUrl}`
    }
    const fairPlayUrl = directorResponse.data.drmObject.fairPlayUrl
    if (fairPlayUrl) {
      directorResponse.data.drmObject.fairPlayUrl = `${Director.getEndpoint()}${fairPlayUrl}`
    }
    const fairPlayCertUrl = directorResponse.data.drmObject.fairPlayCertUrl
    if (fairPlayCertUrl) {
      directorResponse.data.drmObject.fairPlayCertUrl = `${Director.getEndpoint()}${fairPlayCertUrl}`
    }
  }
  return directorResponse
}

export default Director