parks/universal/universal.js

import moment from 'moment-timezone';
import crypto from 'crypto';

import Destination from '../destination.js';
import {attractionType, entityType, queueType, scheduleType, statusType, tagType, returnTimeState} from '../parkTypes.js';

// TODO - move POI to new API

// only return restaurants using these dining types
const wantedDiningTypes = [
  'CasualDining',
  'FineDining',
];

// only return live data for entities in these POI categories (see getPOI)
const wantedLiveDataPOITypes = [
  'Rides',
];

const ignoreShowTypes = [
  'Character',
  'Music',
];

export class UniversalResortBase extends Destination {
  /**
   * @inheritdoc
   */
  constructor(options = {}) {
    options.timezone = options.timezone || 'America/New_York';

    options.secretKey = options.secretKey || '';
    options.appKey = options.appKey || '';
    options.city = options.city || '';
    options.vQueueURL = options.vQueueURL || '';
    options.baseURL = options.baseURL || '';
    options.resortSlug = options.resortSlug || '';

    options.assetsBase = options.assetsBase || '';
    options.resortKey = options.resortKey || '';

    // any custom environment variable prefixes we want to use for this park (optional)
    options.configPrefixes = ['UNIVERSALSTUDIOS'].concat(options.configPrefixes || []);

    super(options);

    // here we can validate the resulting this.config object
    if (!this.config.name) throw new Error('Missing Universal resort name');
    if (!this.config.secretKey) throw new Error('Missing Universal secretKey');
    if (!this.config.appKey) throw new Error('Missing Universal appKey');
    if (!this.config.city) throw new Error('Missing Universal city');
    if (!this.config.vQueueURL) throw new Error('Missing Universal vQueueURL');
    if (!this.config.baseURL) throw new Error('Missing Universal baseURL');
    if (!this.config.resortSlug) throw new Error('Missing Universal resortSlug');

    if (!this.config.assetsBase) throw new Error('Missing Universal assetsBase');
    if (!this.config.resortKey) throw new Error('Missing Universal resortKey');

    const baseURLHostname = new URL(this.config.baseURL).hostname;

    // add out ApiKey to all API requests
    //  add our service token only if this is not the login request
    //  set options.loginRequest=true to skip adding the service token
    this.http.injectForDomain({
      hostname: baseURLHostname,
    }, async (method, url, data, options) => {
      options.headers['X-UNIWebService-ApiKey'] = this.config.appKey;
      if (!options.loginRequest) {
        const token = await this.getServiceToken();
        if (!token) {
          throw new Error('Failed to get service token for Universal API');
        }
        options.headers['X-UNIWebService-Token'] = token;
      }
    });

    // if our API ever returns 401, refetch our service token with a new login
    this.http.injectForDomainResponse({
      hostname: baseURLHostname,
    }, async (response) => {
      if (response.statusCode === 401) {
        // clear out our token and try again
        await this.cache.set('servicetoken', undefined, -1);
        return undefined;
      }

      return response;
    });
  }

  /**
   * Get a service auth token for Universal
   */
  async getServiceToken() {
    let tokenExpiration = null;
    return await this.cache.wrap('servicetoken', async () => {
      // create signature to get access token
      const today = `${moment.utc().format('ddd, DD MMM YYYY HH:mm:ss')} GMT`;
      const signatureBuilder = crypto.createHmac('sha256', this.config.secretKey);
      signatureBuilder.update(`${this.config.appKey}\n${today}\n`);
      // generate hash from signature builder
      //  also convert trailing equal signs to unicode. because. I don't know
      const signature = signatureBuilder.digest('base64').replace(/=$/, '\u003d');

      const resp = await this.http('POST', `${this.config.baseURL}?city=${this.config.city}`, {
        apikey: this.config.appKey,
        signature,
      }, {
        headers: {
          'Date': today,
        },
        // tell our HTTP injector to not add our (currently undefined) service token
        loginRequest: true,
        json: true,
      });

      // remember the expiration time
      const expireTime = resp.body.TokenExpirationUnix * 1000;
      tokenExpiration = Math.max(+new Date() + (1000 * 60 * 60), expireTime - (+new Date()) - (1000 * 60 * 60 * 12));

      return resp.body.Token;
    }, () => {
      // return ttl for cached service token based on data in the token response
      //  can define ttl as a function instead of a Number for dynamic cache timeouts
      return tokenExpiration;
    });
  }

  async _getParks() {
    // cache for 3 hours
    '@cache|180';
    const resp = await this.http('GET', `${this.config.baseURL}/venues?city=${this.config.city}`);
    return resp.body.Results.filter((x) => {
      // skip "parks" which don't require admission (i.e, CityWalk)
      return x.AdmissionRequired;
    });
  }

  /**
   * Get POI data from API for this resort
   * @returns {Object}
   */
  async getPOI() {
    // cache for 1 hour
    '@cache|60';
    const resp = await this.http('GET', `${this.config.baseURL}/pointsofinterest?city=${this.config.city}`);
    return resp.body;
  }

  /**
   * @inheritdoc
   */
  buildBaseEntityObject(data) {
    const entity = super.buildBaseEntityObject(data);

    if (data) {
      entity._tags = [];

      // add location data (if present)
      if (data.Longitude && data.Latitude) {
        entity.location = {
          longitude: data.Longitude,
          latitude: data.Latitude,
        };
      }

      // grab entity name from incoming data
      if (data.MblDisplayName) {
        entity.name = data.MblDisplayName;
      }

      // child swap tag
      if (data.HasChildSwap !== undefined) {
        if (!!data.HasChildSwap) {
          entity._tags.push({
            id: 'childSwap',
            value: true,
          });
        }
      }

      // min height tag
      if (data.MinHeightInInches && data.MinHeightInInches > 0) {
        // convert to CM
        const minHeightInCentimetres = Math.ceil(data.MinHeightInInches * 2.54);

        // add to tags
        entity._tags.push({
          id: 'minimumHeight',
          value: minHeightInCentimetres,
        });
      }
    }

    return entity;
  }

  /**
   * Build the destination entity representing this resort
   */
  async buildDestinationEntity() {
    return {
      ...this.buildBaseEntityObject(),
      _id: `universalresort_${this.config.city}`,
      name: this.config.name,
      entityType: entityType.destination,
      slug: this.config.resortSlug,
    };
  }

  /**
   * Build the park entities for this resort
   */
  async buildParkEntities() {
    const parks = await this._getParks();

    if (parks === undefined) throw new Error('Failed to fetch parks from Universal API');

    return parks.map((x) => {
      return {
        ...this.buildBaseEntityObject(x),
        // all IDs must be strings in ThemeParks.wiki
        _id: x.Id.toString(),
        _destinationId: `universalresort_${this.config.city}`,
        // parented to the resort
        _parentId: `universalresort_${this.config.city}`,
        _contentId: x.ExternalIds.ContentId.slice(0, x.ExternalIds.ContentId.indexOf('.venues.')),
        entityType: entityType.park,
        slug: x.MblDisplayName.replace(/[^a-zA-Z]/g, '').toLowerCase(),
      };
    });
  }

  /**
   * Build the attraction entities for this resort
   */
  async buildAttractionEntities() {
    return (await this.getPOI()).Rides.map((x) => {
      // what kind of attraction is this?
      let type = attractionType.ride; // default to "ride"
      // Hogwarts Express manually tag as "transport"
      if (x.Tags.indexOf('train') >= 0) {
        type = attractionType.transport;
      }

      if (x.MblDisplayName.toLowerCase().indexOf(' - last train') >= 0) {
        return null;
      }
      if (x.MblDisplayName.toLowerCase().indexOf(' - first show') >= 0) {
        return null;
      }

      // TODO - how to classify pool areas like Puka Uli Lagoon?

      return {
        ...this.buildBaseEntityObject(x),
        _id: x.Id.toString(),
        _destinationId: `universalresort_${this.config.city}`,
        _parkId: x.VenueId.toString(),
        _parentId: x.VenueId.toString(),
        entityType: entityType.attraction,
        attractionType: type,
      };
    }).filter((x) => !!x);
  }

  /**
   * Helper function to filter out shows we don't want to show
   * @returns {Array} filtered list of shows
   */
  async _getFilteredShows() {
    return (await this.getPOI()).Shows.filter((show) => {
      // filter out meet & greets and street entertainment
      const matchAnyIgnoreType = show.ShowTypes.find((x) => {
        return ignoreShowTypes.indexOf(x) >= 0;
      });
      if (matchAnyIgnoreType) return false;

      return true;
    });
  }

  /**
   * Build the show entities for this resort
   */
  async buildShowEntities() {
    return (await this._getFilteredShows()).map((show) => {
      return {
        ...this.buildBaseEntityObject(show),
        _id: show.Id.toString(),
        _destinationId: `universalresort_${this.config.city}`,
        _parkId: show.VenueId.toString(),
        _parentId: show.VenueId.toString(),
        entityType: entityType.show,
      };
    });
  }

  /**
   * Build the restaurant entities for this resort
   */
  async buildRestaurantEntities() {
    return (await this.getPOI()).DiningLocations.filter((x) => {
      // only return dining locations that match our wantedDiningTypes list
      //  eg. CasualDining, FineDining - skip coffee carts
      if (!x.DiningTypes) return false;
      return !!x.DiningTypes.find((type) => {
        return wantedDiningTypes.indexOf(type) >= 0;
      });
    }).map((x) => {
      return {
        ...this.buildBaseEntityObject(x),
        _id: x.Id.toString(),
        _destinationId: `universalresort_${this.config.city}`,
        _parkId: x.VenueId.toString(),
        _parentId: x.VenueId.toString(),
        entityType: entityType.restaurant,
      };
    });
  }

  /**
   * Fetch wait time data
   * @private
   */
  async _fetchWaitTimes() {
    // cache for 1 minute
    '@cache|1';

    const resp = await this.http(
      'GET',
      `${this.config.assetsBase}/${this.config.resortKey}/wait-time/wait-time-attraction-list.json`,
    );

    return resp.body;
  }

  /**
   * Get the current state of virtual queues for the resort
   * @private
   */
  async _fetchVirtualQueueStates() {
    // cache for 1 minute
    '@cache|1';
    const virtualData = await this.http('GET', `${this.config.baseURL}/Queues`, {
      city: this.config.city,
      page: 1,
      pageSize: 'all',
    });
    return virtualData?.body?.Results;
  }

  /**
   * Fetch the virtual queue state for a specific ride
   * @private
   */
  async _fetchVirtualQueueStateForRide(queueId) {
    // cache for 1 minute
    '@cache|1';
    const todaysDate = (await this.getTimeNowMoment()).format('MM/DD/YYYY');
    const res = await this.http(
      'GET',
      `${this.config.baseURL}/${this.config.vQueueURL}/${queueId}`, {
      page: 1,
      pageSize: 'all',
      city: this.config.city,
      appTimeForToday: todaysDate,
    });

    return res.body;
  }

  /**
   * @inheritdoc
   */
  async buildEntityLiveData() {
    // fetch standard wait times
    const waittime = await this._fetchWaitTimes();
    // fetch virtual lines state
    //  this returns all the virtual queues and if they are running
    const vQueueData = await this._fetchVirtualQueueStates();

    const returnLiveData = [];

    const findOrCreateLiveData = (id) => {
      let liveData = returnLiveData.find((x) => x._id === id);
      if (!liveData) {
        liveData = {
          _id: `${id}`,
          status: statusType.closed,
        };
        returnLiveData.push(liveData);
      }
      return liveData;
    };

    // start with virtual queues
    for (const vQueue of vQueueData) {
      if (vQueue.IsEnabled) {
        // hurray! we found some vqueue data in the state object
        //  and it's enabled!

        // get details about this queue
        const vQueueFetchedData = await this._fetchVirtualQueueStateForRide(vQueue.Id);

        // find and return the earliest appointment time available
        const nextSlot = vQueueFetchedData.AppointmentTimes.reduce((p, x) => {
          const startTime = moment.tz(x.StartTime, this.config.timezone);
          if (p === undefined || startTime.isBefore(p.startTime)) {
            const endTime = moment.tz(x.EndTime, this.config.timezone);
            return {
              startTime,
              endTime,
            };
          }
          return p;
        }, undefined);

        const liveDataObject = findOrCreateLiveData(vQueue.QueueEntityId);

        if (!liveDataObject.queue) {
          liveDataObject.queue = {};
        }

        liveDataObject.queue[queueType.returnTime] = {
          returnStart: nextSlot === undefined ? null : nextSlot.startTime.format(),
          returnEnd: nextSlot === undefined ? null : nextSlot.endTime.format(),
          // TODO - can we tell the difference between temporarily full and finished for the day?
          state: nextSlot === undefined ? returnTimeState.temporarilyFull : returnTimeState.available,
        };
      }
    }

    // loop over standby/single-rider queues
    waittime.forEach((attraction) => {
      if (!attraction || !attraction.queues) return;

      let attractionLiveDataObject = null;
      let attractionHasOperatingQueue = false;
      let attractionIsBrokenDown = false;

      attraction.queues.forEach((queue) => {
        const rideIdObj = queue.alternate_ids.find((x) => {
          return x.system_name == 'POI';
        });

        if (!rideIdObj) return;
        const rideId = rideIdObj.system_id;

        if (!attractionLiveDataObject) {
          attractionLiveDataObject = findOrCreateLiveData(rideId);
        }

        switch (queue.queue_type) {
          case 'STANDBY':
            if (queue.status == 'OPEN' || queue.status == 'RIDE_NOW') {
              // figure out the wait time
              let waitTime = queue.display_wait_time;
              if (waitTime === undefined || waitTime === null) {
                // assume time of zero if no wait time is given and status is RIDE_NOW
                if (queue.status == 'RIDE_NOW' || queue.status == 'CLOSES_AT') {
                  waitTime = 0;
                } else {
                  // no wait time available, use null
                  waitTime = null;
                }
              }

              // add to queue object
              if (!attractionLiveDataObject.queue) {
                attractionLiveDataObject.queue = {};
              }
              attractionLiveDataObject.queue[queueType.standBy] = {
                waitTime,
              };

              // mark that we have a valid operating queue, so the attraction is open
              attractionHasOperatingQueue = true;
            }

            if (queue.status == 'BRIEF_DELAY' || queue.status == 'WEATHER_DELAY') {
              attractionIsBrokenDown = true;
            }

            if (queue.status == 'OPENS_AT' && queue.opens_at) {
              // add operatingHours entry for this queue if we have it
              if (!attractionLiveDataObject.operatingHours) {
                attractionLiveDataObject.operatingHours = [];
              }
              // TODO - look for existing operating hours and merge them
              attractionLiveDataObject.operatingHours.push({
                type: "OPERATING",
                startTime: queue.opens_at,
                endTime: null,
              });
            }

            // DEBUG - gather various potential queue status types
            if (queue.status != 'OPEN' && queue.status != 'OPENS_AT' && queue.status != 'CLOSED' && queue.status != 'CLOSES_AT' && queue.status != 'BRIEF_DELAY' && queue.status != 'N/A' && queue.status != 'RIDE_NOW' && queue.status != 'WEATHER_DELAY') {
              console.error("Unknown queue status", queue.status, "for", attraction.name, "queue", queue.queue_type, "status", queue.status, "wait", queue.display_wait_time, "rideId", rideId, "attractionLiveDataObject", attractionLiveDataObject, "attractionHasOperatingQueue", attractionHasOperatingQueue, "queue", queue);
              debugger;
            }

            break;
          case 'SINGLE':
            // valid statuses: CLOSED, AT_CAPACITY, ...
            if (queue.status == 'OPEN' && attraction.has_single_rider) {
              if (!attractionLiveDataObject.queue) {
                attractionLiveDataObject.queue = {};
              }

              // single rider queues
              attractionLiveDataObject.queue[queueType.singleRider] = {
                // doesn't return actual wait times, but return something to show it's operating
                waitTime: null, //queue.wait_time, eg. 995 for Mummy
              };
              attractionHasOperatingQueue = true;
            } // ignore AT_CAPACITY, CLOSED

            break;
          default:
            this.log(`Unknown queue type ${queue.queue_type}`);
            break;
        }
      });

      // TODO - maintenance/refurb status?
      if (attractionLiveDataObject) {
        if (attractionIsBrokenDown) {
          attractionLiveDataObject.status = statusType.down;
        }
        else if (attractionHasOperatingQueue) {
          attractionLiveDataObject.status = statusType.operating;
        }
        else {
          attractionLiveDataObject.status = statusType.closed;
        }
      }
    });

    // add show times
    const showtimes = await this._getFilteredShows();
    showtimes.forEach((show) => {
      const showEntry = findOrCreateLiveData(show.Id.toString());
      showEntry.status = statusType.operating;
      showEntry.showtimes = show.StartDateTimes.map((x) => {
        const timeObj = moment.tz(x, this.config.timezone);
        if (timeObj.isBefore(this.getTimeNowMoment())) {
          return null;
        }
        return {
          // TODO - filter out shows that were over an hour in the past?
          type: "Performance Time",
          startTime: timeObj.format(),
          endTime: timeObj.format(),
        };
      }).filter((x) => !!x);
    });

    return returnLiveData;
  }

  /**
   * Convert a time string from the API to a valid timestamp in our timezone
   * @param {string} time 
   * @returns {string}
   */
  _stringTimeToLocalTime(time) {
    return moment.tz(time, this.config.timezone).format();
  }

  /**
   * Get the latest raw opening hours for a given venue
   */
  async getLatestOpeningHoursForVenue(venueId) {
    // cache for 3 hours
    '@cache|180';
    const now = this.getTimeNowMoment();
    const cal = await this.http('GET', `${this.config.baseURL}/venues/${venueId}/hours`, {
      endDate: now.clone().add(190, 'days').format('MM/DD/YYYY'),
    });

    const ret = [];
    // loop over all hours data the API returns
    cal.body.forEach((todaysCal) => {
      // skip any Closed dates, just return nothing
      if (todaysCal.VenueStatus === 'Closed') return;

      ret.push({
        date: todaysCal.Date,
        openingTime: this._stringTimeToLocalTime(todaysCal.OpenTimeString),
        closingTime: this._stringTimeToLocalTime(todaysCal.CloseTimeString),
        type: scheduleType.operating,
      });

      if (todaysCal.EarlyEntryString) {
        // extra hours
        ret.push({
          date: todaysCal.Date,
          openingTime: this._stringTimeToLocalTime(todaysCal.EarlyEntryString),
          closingTime: this._stringTimeToLocalTime(todaysCal.OpenTimeString),
          type: scheduleType.extraHours,
        });
      }

      // TODO - handle todaysCal.SpecialEntryString (when these exist)
      if (todaysCal.SpecialEntryString) {
        this.emit('error', new Error(`Unknown Universal SpecialEntryString ${todaysCal.SpecialEntryString}`));
      }
    });

    return ret;
  }

  /**
   * @inheritdoc
   */
  async buildEntityScheduleData() {
    // get list of venues to fetch schedules for
    const venues = (await this.getParkEntities()).map((x) => {
      return x._id;
    });

    // loop over each venue and build up our return object
    const returnData = [];
    for (let i = 0; i < venues.length; i++) {
      const venueScheduleData = await this.getLatestOpeningHoursForVenue(venues[i]);
      returnData.push({
        _id: venues[i],
        schedule: venueScheduleData,
      });
    }
    return returnData;
  }
}

export class UniversalOrlando extends UniversalResortBase {
  /**
   * @inheritdoc
   */
  constructor(options = {}) {
    options.name = options.name || 'Universal Orlando Resort';
    options.city = options.city || 'orlando';
    options.timezone = options.timezone || 'America/New_York';
    options.resortSlug = options.resortSlug || 'universalorlando';
    options.resortKey = options.resortKey || 'uor';

    super(options);
  }
}

export class UniversalStudios extends UniversalResortBase {
  /**
   * @inheritdoc
   */
  constructor(options = {}) {
    options.name = options.name || 'Universal Studios';
    options.city = options.city || 'hollywood';
    options.timezone = options.timezone || 'America/Los_Angeles';
    options.resortSlug = options.resortSlug || 'universalstudios';
    options.resortKey = options.resortKey || 'ush';

    super(options);
  }
}