Home Reference Source Test Repository

src/controller/listening.js

import NPROneSDK from './../index';
import createRecommendations from './../util/recommendation-creator';
import Action from './../model/action';
import Rating from './../model/rating';
import Logger from './../util/logger';
import FetchUtil from './../util/fetch-util';


/**
 * Encapsulates all of the logic for communication with the [Listening Service](https://dev.npr.org/api/#/listening)
 * in the NPR One API.
 *
 * Note that consumers should not be accessing this class directly but should instead use the provided pass-through
 * functions in the main {@link NprOneSDK} class.
 *
 * @example <caption>Implementing a rudimentary 'Explore' view</caption>
 * const nprOneSDK = new NprOneSDK();
 * nprOneSDK.config = { ... };
 * nprOneSDK.getRecommendationsFromChannel('recommended')
 *     .then((recommendations) => {
 *         // in a real app, the user would select a piece; here we've simulated them selecting one at index 3
 *         const selectedRecommendationId = recommendations[3].attributes.uid;
 *         return nprOneSDK.queueRecommendationFromChannel('recommended', selectedRecommendationId);
 *      })
 *     .then(() => {
 *         nprOneSDK.getRecommendation(); // proceed to play the recommendation
 *     });
 */
export default class Listening {
    /**
     * Initializes the controller class with private variables needed later on.
     */
    constructor() {
        /** @type {Rating[]} Ratings which are queued to be sent to NPR
         * @private */
        this._queuedRatings = [];
        /** @type {Rating[]} Ratings which have already been sent, for debugging purposes
         * @private */
        this._sentRatings = [];
        /** @type {Array<Recommendation>} Unrated recommendations which represent the latest
         *  recommendations from the API, relies heavily upon numeric key/index
         * @private */
        this._flowRecommendations = [];
        /** @type {boolean} Flow fetches need to be synchronous
         * @private */
        this._flowFetchActive = false;
        /** @type {Promise<Recommendation>}
         * @private */
        this._flowPromise = null;
        /** @type {boolean} Whether ads are blocked by the browser.
         * @private */
        this._adsBlocked = false;
        /** @type {Object} Cached recommendations from channels other than the main flow channel of 'npr'.
         * A key-value store where the key is the name of the channel and the value is an array of recommendations.
         * @private */
        this._channelRecommendations = {};

        // Ad-blocker detection, used when/if we encounter sponsorship in the flow
        fetch('https://adswizz.com', { mode: 'no-cors' })
            .catch(() => {
                fetch('https://delivery-s3.adswizz.com', { mode: 'no-cors' })
                    .catch(e => {
                        Logger.debug('Ads are blocked. ', e);
                        this._adsBlocked = true;
                    });
            });
    }

    /**
     * Get a recommendation from NPR.
     *
     * Caution: the resulting recommendation may have been returned previously and must be checked
     * to ensure the same recommendation is not played twice.
     *
     * @param {string} [uid='']           Optional; a UID for a specific recommendation to play. In 99% of use cases, this is not needed.
     * @param {string} [channel='npr']    Optional; a channel to pull the recommendation from; the main flow channel of `npr` is used as the default. In 99% of use cases, this does not need to be changed.
     * @returns {Promise<Recommendation>}
     */
    getRecommendation(uid = '', channel = 'npr') {
        this._flowPromise = this._advanceFlowRecommendations(channel, uid);

        return this._flowPromise;
    }

    /**
     * Return possible recommendations that may come next in the flow. Useful for
     * pre-caching audio and displaying upcoming recommendations.
     *
     * Recommendations returned are not guaranteed to always come next in the flow.
     *
     * @experimental
     * @param {string} [channel='npr']   A channel to pull the next recommendation from
     * @returns {Promise<Array<Recommendation>>}
     */
    getUpcomingFlowRecommendations(channel = 'npr') {
        if (this._flowRecommendations.length > 0) {
            return Promise.resolve(this._flowRecommendations);
        }

        return this._getChannelRecommendations(channel);
    }

    /**
     * Makes a new API call to get a list of recommendations. This is NOT intended for regular piece-by-piece consumption;
     * this function is designed to be used for consumers implementing e.g. the Explore view from the NPR One apps,
     * where the client displays a list or grid of content, and the user can select a piece to listen to next.
     * It is hard-coded to use the "recommended" channel by default, although other channels can be used also. That said,
     * you should really never use this with channel "npr" (the main flow channel), as this is not how that content is
     * intended to be consumed.
     *
     * @param {string} [channel='recommended']   A non-flow (i.e. non-`npr`) channel to retrieve a list of recommendations from
     * @returns {Promise<Array<Recommendation>>}
     */
    getRecommendationsFromChannel(channel = 'recommended') {
        const _channel = (!channel || typeof channel !== 'string') ? 'recommended' : channel;

        let prerequisitePromise = Promise.resolve(true);
        // Send any pending ratings we have first, just in case it impacts the results from the upcoming recommendations call
        if (this._queuedRatings.length > 0) {
            prerequisitePromise = this._sendRatings();
        }

        return prerequisitePromise.then(this._getChannelRecommendations.bind(this, _channel, null))
            .then((recommendations) => {
                /* istanbul ignore if: defensive coding; should never really happen */
                if (!this._channelRecommendations) {
                    this._channelRecommendations = {};
                }
                this._channelRecommendations[_channel] = recommendations;
                return recommendations;
            });
    }

    /**
     * This synchronous method is intended to be used alongside {@link getRecommendationsFromChannel}.
     * Once you have a list of recommendations from a channel and an audio story has been selected to play, this method
     * ensures that the correct ratings (actions) will be sent and the flow of audio will continue appropriately with
     * the necessary API calls.
     * If the recommendation with the given UID can be found, it is delivered immediately to be played.
     * Importantly, this function also returns the selected recommendation on a subsequent call to getRecommendation
     * (assuming no other ratings are sent in between), so that the consumer can assume that the correct recommendation
     * will be played next.
     *
     * @param {string} channel   The channel used in the original call to `getRecommendationsFromChannel()`
     * @param {string} uid       The unique ID of the item to queue up for the user
     * @returns {Recommendation}
     * @throws {TypeError} If no valid channel or UID is passed in
     * @throws {Error} If no recommendations for this channel were previously cached, or if the UID was not found in that cached list
     */
    queueRecommendationFromChannel(channel, uid) {
        if (!channel || typeof channel !== 'string') {
            throw new TypeError('Must pass in a valid channel to queueRecommendationFromChannel()');
        }
        if (!uid || typeof uid !== 'string') {
            throw new TypeError('Must pass in a valid uid to queueRecommendationFromChannel()');
        }
        if (!(channel in this._channelRecommendations) ||
            this._channelRecommendations[channel].length === 0) {
            throw new Error(`Results from channel "${channel}" are not cached. ` +
                'You must call getRecommendationsFromChannel() first.');
        }

        for (const recommendation of this._channelRecommendations[channel]) {
            if (recommendation.attributes.uid === uid) {
                /* istanbul ignore if: defensive coding; should never really happen */
                if (!this._flowRecommendations) {
                    this._flowRecommendations = [recommendation];
                } else {
                    this._flowRecommendations = [recommendation].concat(this._flowRecommendations);
                }
                return recommendation;
            }
        }
        throw new Error(`Unable to find story with uid ${uid} ` +
            `in cached list of recommendations from channel "${channel}".`);
    }

    /**
     * Retrieves a user's history as an array of recommendation objects.
     *
     * @returns {Promise<Array<Recommendation>>}
     */
    getHistory() {
        const url = `${NPROneSDK.getServiceUrl('listening')}/history`;

        return FetchUtil.nprApiFetch(url).then(this._createRecommendations.bind(this));
    }


    /**
     * Resets the current flow for the user. Note that 99% of the time, clients will never have to do this (and it is
     * generally considered an undesirable user experience), but in a few rare cases it might be needed. The best example
     * is after calling `setUserStation()` if the current recommendation is of `type === 'stationId'`; in this case,
     * resetting the flow may be necessary in order to make the user aware that they successfully changed their station.
     *
     * @example
     * let currentRecommendation = nprOneSDK.getRecommendation();
     * playAudio(currentRecommendation); // given a hypothetical playAudio() function in your app
     * ...
     * nprOneSDK.setUserStation(123)
     *     .then(() => {
     *         if (currentRecommendation.attributes.type === 'stationId') {
     *             nprOneSDK.resetFlow()
     *                 .then(() => {
     *                     currentRecommendation = nprOneSDK.getRecommendation();
     *                     playAudio(currentRecommendation);
     *                 });
     *         }
     *     });
     *
     * @returns {Promise}
     */
    resetFlow() {
        let prerequisitePromise = Promise.resolve(true);

        if (this._flowRecommendations && this._flowRecommendations.length) {
            // Send any pending ratings we have first, just in case it impacts the results from the upcoming recommendations call
            if (this._queuedRatings.length > 0) {
                prerequisitePromise = this._sendRatings(false);
            }

            return prerequisitePromise.then(() => {
                this._flowRecommendations = [];
                this._flowFetchActive = false;
                this._flowPromise = null;

                return true;
            });
        }

        return prerequisitePromise;
    }

    /**
     * Given a valid JSON recommendation object, the flow will advance as
     * normal from this recommendation. This method has been created for
     * a special case (Chromecast sharing) and is not intended for use
     * in a traditional SDK implementation.
     *
     * NOTE: this function will overwrite ALL existing flow
     * recommendations.
     *
     * @param {Object} json   Recommendation JSON Object (CDoc+JSON)
     * @returns {Recommendation}
     */
    resumeFlowFromRecommendation(json) {
        const recommendations = this._createRecommendations(json);
        this._flowRecommendations = recommendations;
        return this._flowRecommendations[0];
    }

    /**
     * Advances the flow (retrieves new recommendations from the API).
     *
     * @param {string} channel
     * @param {string} uid
     * @returns {Promise<Array<Recommendation>>}
     * @throws {Error} If there are no recommendations to return
     * @private
     */
    _advanceFlowRecommendations(channel, uid) {
        if (this._flowFetchActive) {
            Logger.debug('A listening service API request is already active, ' +
                'returning existing promise if one exists.');

            /* istanbul ignore else: defensive coding */
            if (this._flowPromise) {
                return this._flowPromise;
            }
            /* istanbul ignore next: defensive coding */
            return Promise.reject(new Error('No recommendations available. Try again later.'));
        }

        // if given a UID, we check first to see if we already have the recommendation cached
        if (uid && !!this._flowRecommendations && this._flowRecommendations.length > 0) {
            let isRecommendationFound = false;
            this._flowRecommendations.forEach((recommendation, index) => {
                if (!isRecommendationFound && recommendation.attributes.uid === uid) {
                    this._flowRecommendations = this._flowRecommendations.slice(index);
                    isRecommendationFound = true;
                }
            });
            if (isRecommendationFound) {
                Logger.debug(`Recommendation with UID ${uid} was already queued up. ` +
                    'Returning the cached version instead of making a new API call.');
                return Promise.resolve(this._flowRecommendations[0]);
            }
        }

        this._flowFetchActive = true;
        return this._getFlowRecommendations(channel, uid)
            .then((recommendations) => {
                this._flowFetchActive = false;
                if (recommendations.length <= 0) {
                    Logger.error('API returned no recommendations.');
                }
                this._flowRecommendations = this._filterIncomingRecommendations(recommendations);
                if (!this._flowRecommendations[0]) {
                    throw new Error('All recommendations exhausted!');
                }
                return this._flowRecommendations[0];
            })
            .catch(error => {
                this._flowFetchActive = false;
                throw error;
            });
    }

    /**
     * Provide any necessary filter to incoming recommendations if needed
     *
     * @param {Array<Recommendation>} recommendations
     * @private
     */
    _filterIncomingRecommendations(recommendations) {
        if (this._adsBlocked) {
            const unfilteredCount = recommendations.length;
            const _recommendations = recommendations.filter(rec => !rec.isSponsorship());
            const filteredCount = unfilteredCount - _recommendations.length;
            if (filteredCount > 0) {
                Logger.debug(`Filtered ${filteredCount} ad(s).`);
            }
            return _recommendations;
        }

        return recommendations;
    }

    /**
     * Private method to facilitate communication of a rated recommendation.
     *
     * @param {Rating} rating
     * @private
     */
    _recordRating(rating) {
        if (this._queuedRatingsContainsRating(rating)) {
            return; // no need to take action for the same rating twice
        }

        Logger.debug(`Queued rating: ${rating}`);
        this._queuedRatings.push(rating);

        // Only one of these should ever fire, but this is easiest way to do the lookup
        for (const action of Action.getFlowAdvancingActions()) {
            if (rating.rating === action) {
                this.getRecommendation();
                break;
            }
        }
    }

    /**
     * Request for recommendations from NPR specifically for the flow as opposed to
     * other channels which will not change the current flow.
     *
     * @param {string} channel
     * @param {string} uid
     * @returns {Promise<Array<Recommendation>>}
     */
    _getFlowRecommendations(channel, uid) {
        for (const action of Action.getFlowAdvancingActions()) {
            if (this._queuedRatingsContainsAction(action)) {
                return this._sendRatings();
            }
        }

        if (!uid) {
            // Only perform the initial recommendation call if all flow recommendations are exhausted
            if (this._flowRecommendations.length > 0) {
                return Promise.resolve(this._flowRecommendations);
            }
        }

        return this._getChannelRecommendations(channel, uid);
    }

    /**
     * @param {string} channel
     * @param {string} [uid='']
     * @returns {Promise<Array<Recommendation>>}
     * @private
     */
    _getChannelRecommendations(channel, uid = '') {
        const _channel = (!channel || typeof channel !== 'string') ? 'npr' : channel;

        let url = `${NPROneSDK.getServiceUrl('listening')}/recommendations?channel=${_channel}`;
        url += uid ? `&sharedMediaId=${uid}` : '';

        return FetchUtil.nprApiFetch(url).then(this._createRecommendations.bind(this));
    }

    /**
     * Create recommendation objects from collection doc
     *
     * @param {Object} json - collection doc
     * @returns {Array<Recommendation>}
     * @private
     */
    _createRecommendations(json) {
        const recommendations = createRecommendations(json);
        const recordRating = this._recordRating.bind(this);
        recommendations.map(rec => rec.setRatingReceivedCallback(recordRating));

        return recommendations;
    }

    /**
     * Send batched ratings
     *
     * @param {boolean} [recommendMore=true] - determines if additional recommendations should be returned
     * @returns {Promise<Array<Recommendation>>}
     * @private
     */
    _sendRatings(recommendMore = true) {
        /* istanbul ignore if: defensive coding */
        if (this._queuedRatings.length === 0) {
            Logger.error('Things have gone drastically wrong, this: ', this);
            return Promise.reject(new Error('No queued ratings to send.'));
        }

        let url = NPROneSDK.getServiceUrl('listening');
        url += `/ratings?recommend=${recommendMore.toString()}`;
        if (recommendMore) {
            const latestRating = this._queuedRatings.slice(-1).pop();
            if (latestRating._actionUrl && latestRating.rating === Action.TAPTHRU) {
                url = latestRating._actionUrl;
            } else {
                url = latestRating._recommendationUrl;
            }
        }

        const ratingsToSend = [];
        this._queuedRatings.forEach(rating => {
            /* istanbul ignore else: defensive coding */
            if (!rating._hasSent) {
                ratingsToSend.push(rating);
            }
        });

        const options = {
            method: 'POST',
            body: JSON.stringify(ratingsToSend, Rating.privateMemberReplacer),
        };

        Logger.debug('Sending Ratings: ', ratingsToSend.join(', '));

        return FetchUtil.nprApiFetch(url, options)
            .then((json) => {
                // Loop through all queued ratings and mark as sent
                ratingsToSend.forEach((rating) => {
                    const _rating = rating;
                    _rating._hasSent = true;
                    this._sentRatings.push(_rating);
                    this._queuedRatings.splice(this._queuedRatings.indexOf(_rating), 1);
                });
                return this._createRecommendations(json);
            });
    }

    /**
     * Returns whether currently queued ratings contain a specific rating
     *
     * This is not a deep copy check and relies on mediaId & rating string
     *
     * @param {Rating} rating
     * @returns {boolean}
     * @private
     */
    _queuedRatingsContainsRating(rating) {
        return this._queuedRatings.some(qr => qr.rating === rating.rating && qr.mediaId === rating.mediaId);  // eslint-disable-line
    }

    /**
     * Returns whether the currently queued ratings contains a specific action
     *
     * @param {string} action
     * @returns {boolean}
     * @private
     */
    _queuedRatingsContainsAction(action) {
        return this._queuedRatings.some(qr => qr.rating === action);
    }
}