1030 lines
43 KiB
JavaScript
1030 lines
43 KiB
JavaScript
(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.jiboRadio = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
|
|
"use strict";
|
|
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
return new (P || (P = Promise))(function (resolve, reject) {
|
|
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); }
|
|
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
});
|
|
};
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
const axios_1 = require("axios");
|
|
const jibo_cai_utils_1 = require("jibo-cai-utils");
|
|
const crypto = require("crypto");
|
|
const dns = require("dns");
|
|
const events_1 = require("events");
|
|
const HLS = require("hls.js");
|
|
const http = require("http");
|
|
const icecast = require("icecast");
|
|
const jibo_log_1 = require("jibo-log");
|
|
const url = require("url");
|
|
const HJ_VOLUME = 1;
|
|
const VOLUME_NORMAL = 0.06;
|
|
const DNS_TIMEOUT = 5000;
|
|
const STREAM_TIMEOUT = 10000;
|
|
const promisify = jibo_cai_utils_1.PromiseUtils.promisify;
|
|
var MediaType;
|
|
(function (MediaType) {
|
|
MediaType["Music"] = "Music";
|
|
MediaType["Talk"] = "Talk";
|
|
MediaType["Other"] = "Other";
|
|
})(MediaType = exports.MediaType || (exports.MediaType = {}));
|
|
var Mode;
|
|
(function (Mode) {
|
|
Mode["HLS"] = "HLS";
|
|
Mode["Shoutcast"] = "Shoutcast";
|
|
Mode["PLS"] = "PLS";
|
|
})(Mode = exports.Mode || (exports.Mode = {}));
|
|
var Locality;
|
|
(function (Locality) {
|
|
Locality["Local"] = "Local";
|
|
Locality["National"] = "National";
|
|
})(Locality = exports.Locality || (exports.Locality = {}));
|
|
const DEVICE_ID_SALT = '6464e9ec0098c3e04d65392f607b9ed9';
|
|
class RadioPlayer extends events_1.EventEmitter {
|
|
constructor() {
|
|
super();
|
|
this._promises = new jibo_cai_utils_1.CancelTokenSession();
|
|
this._isCurrentlySpeaking = false;
|
|
this._songDataTimers = [];
|
|
this._hjHeard = () => {
|
|
this._volume = HJ_VOLUME;
|
|
};
|
|
this._listenFinished = () => {
|
|
if (!this._isCurrentlySpeaking) {
|
|
this._volume = this._volumePlugin.currentVolume;
|
|
}
|
|
};
|
|
this._volumeChanged = (volume) => {
|
|
this._volume = volume;
|
|
};
|
|
this._hlsMediaAttached = () => {
|
|
if (this._hls) {
|
|
this._hls.loadSource(this._hlsURI);
|
|
}
|
|
};
|
|
this._hlsManifestParsed = (event, data) => {
|
|
if (this._hls) {
|
|
this._log.info(`Manifest loaded, found ${data.levels.length} quality levels`);
|
|
this._mode = Mode.HLS;
|
|
this._volume = this._volumePlugin.currentVolume;
|
|
this.resume();
|
|
}
|
|
};
|
|
this._hlsFragParsing = (event, fragment) => __awaiter(this, void 0, void 0, function* () {
|
|
this._log.debug(event, fragment);
|
|
if (fragment.frag.title) {
|
|
try {
|
|
this._emitSongData(this._processHLSMetadata(fragment.frag.title));
|
|
}
|
|
catch (err) {
|
|
this._log.info('Problem parsing HLS stream metadata; trying sideband', err);
|
|
this._emitSongData(yield this._getSidebandData(), true);
|
|
}
|
|
}
|
|
});
|
|
this._handleHLSErrors = (event, data) => {
|
|
if (!this._hls) {
|
|
return;
|
|
}
|
|
if (!data.fatal) {
|
|
this._log.info('HLS warning', data);
|
|
return;
|
|
}
|
|
switch (data.type) {
|
|
case HLS.ErrorTypes.MEDIA_ERROR:
|
|
if (!this._recoveringMedia) {
|
|
this._log.info('Trying to recover from media error', data);
|
|
this._recoveringMedia =
|
|
setTimeout(() => this._recoveringMedia = null, 2000);
|
|
this._hls.recoverMediaError();
|
|
}
|
|
else if (!this._swappingAudioCodec) {
|
|
this._log.info('Swapping audio codec this time, then trying again to recover from media error', data);
|
|
clearTimeout(this._recoveringMedia);
|
|
this._recoveringMedia = null;
|
|
this._swappingAudioCodec = true;
|
|
setTimeout(() => this._swappingAudioCodec = false, 2000);
|
|
this._hls.swapAudioCodec();
|
|
this._hls.recoverMediaError();
|
|
}
|
|
else {
|
|
this._recoveringMedia = null;
|
|
this._swappingAudioCodec = false;
|
|
this._handleStreamError(data);
|
|
}
|
|
break;
|
|
default:
|
|
this._handleStreamError(data);
|
|
break;
|
|
}
|
|
};
|
|
this._handleMediaPlaying = () => {
|
|
this._mediaTagPlayPromise = null;
|
|
this._streamStarted();
|
|
this._resolveStreamPromise();
|
|
};
|
|
this._handleAudioError = () => {
|
|
if (!this._log) {
|
|
return;
|
|
}
|
|
if (this._audio) {
|
|
this._audio.removeEventListener('loadeddata', this._handleMediaPlaying);
|
|
this._audio.removeEventListener('stalled', this._handleAudioError);
|
|
this._audio.removeEventListener('error', this._handleAudioError);
|
|
}
|
|
this._mediaTagPlayPromise = null;
|
|
const code = this._audio && this._audio.error && this._audio.error.code;
|
|
if (code !== MediaError.MEDIA_ERR_ABORTED) {
|
|
const err = RadioPlayer._getMediaError(code);
|
|
if (this._mode === Mode.PLS) {
|
|
this._nextPLSStream(err);
|
|
}
|
|
else {
|
|
this._handleStreamError(err);
|
|
}
|
|
}
|
|
};
|
|
this._handleVideoError = () => {
|
|
if (!this._log) {
|
|
return;
|
|
}
|
|
if (this._video) {
|
|
this._video.removeEventListener('loadeddata', this._handleMediaPlaying);
|
|
this._video.removeEventListener('error', this._handleAudioError);
|
|
}
|
|
this._mediaTagPlayPromise = null;
|
|
const code = this._video && this._video.error && this._video.error.code;
|
|
if (code !== MediaError.MEDIA_ERR_ABORTED) {
|
|
this._handleStreamError(RadioPlayer._getMediaError(code));
|
|
}
|
|
};
|
|
if (!HLS.isSupported()) {
|
|
throw new Error('Oh no! HTTP Live Streaming isn\'t supported!');
|
|
}
|
|
}
|
|
static _hash(name) {
|
|
const shasum = crypto.createHash('sha256');
|
|
shasum.update(`${DEVICE_ID_SALT}-${name}`);
|
|
return shasum.digest('hex');
|
|
}
|
|
static _songDataDifferent(left, right) {
|
|
return !!left && !right
|
|
|| !left && !!right
|
|
|| left.mediaType !== right.mediaType
|
|
|| left.artist !== right.artist
|
|
|| left.title !== right.title;
|
|
}
|
|
static _promiseTimeout(promise, millis) {
|
|
return Promise.race([
|
|
promise,
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), millis)),
|
|
]);
|
|
}
|
|
init(serialNumber, releaseVersion, country, lat, lng, listenEvents, volumePlugin) {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
this._deviceId = RadioPlayer._hash(serialNumber);
|
|
this._releaseVersion = releaseVersion;
|
|
this._country = country;
|
|
this._lat = lat;
|
|
this._lng = lng;
|
|
this._listenEvents = listenEvents;
|
|
this._listenEvents.hjHeard.on(this._hjHeard);
|
|
this._listenEvents.hjOnly.on(this._listenFinished);
|
|
this._listenEvents.noGlobalMatch.on(this._listenFinished);
|
|
this._listenEvents.nonInterruptingGlobal.on(this._listenFinished);
|
|
this._volumePlugin = volumePlugin;
|
|
this._volumePlugin.on('change', this._volumeChanged);
|
|
this._log = new jibo_log_1.Log('Jibo.Radio');
|
|
});
|
|
}
|
|
getCountry() {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
return this._country;
|
|
});
|
|
}
|
|
resizeArtwork(uri, width) {
|
|
return uri;
|
|
}
|
|
pause() {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
if (this._mediaTagPlayPromise) {
|
|
try {
|
|
yield this._mediaTagPlayPromise;
|
|
}
|
|
catch (err) {
|
|
this._log.info('HTMLMediaElement#play rejected', err);
|
|
}
|
|
this._mediaTagPlayPromise = null;
|
|
}
|
|
if (this._audio) {
|
|
this._audio.pause();
|
|
}
|
|
if (this._video) {
|
|
this._video.pause();
|
|
}
|
|
});
|
|
}
|
|
resume() {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
if (this._mode === 'HLS' && !this._video
|
|
|| this._mode !== 'HLS' && !this._audio) {
|
|
return;
|
|
}
|
|
try {
|
|
yield this.pause();
|
|
}
|
|
catch (err) {
|
|
this._log.info('Error pausing stream', err);
|
|
}
|
|
this._mediaTagPlayPromise = this._promises.wrap(this._mode === Mode.HLS
|
|
? this._video.play()
|
|
: this._audio.play()).catch(err => {
|
|
this._log.info('HTMLMediaElement#play rejected', err);
|
|
if (this._mode === Mode.HLS) {
|
|
this._handleVideoError();
|
|
}
|
|
else {
|
|
this._handleAudioError();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
stop() {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
this._lastSongData = undefined;
|
|
if (this._songDataSession) {
|
|
this._songDataSession.cancel();
|
|
this._songDataSession = null;
|
|
}
|
|
this._songDataTimers.forEach(clearTimeout);
|
|
this._songDataTimers = [];
|
|
if (this._pollSongData) {
|
|
clearTimeout(this._pollSongData);
|
|
this._pollSongData = null;
|
|
}
|
|
this._icecastClient = null;
|
|
if (this._shoutcastServer) {
|
|
this._shoutcastServer.close();
|
|
this._shoutcastServer = null;
|
|
}
|
|
if (this._hls) {
|
|
this._hls.off(HLS.Events.ERROR, this._handleHLSErrors);
|
|
this._hls.off(HLS.Events.FRAG_PARSING_METADATA, this._hlsFragParsing);
|
|
this._hls.off(HLS.Events.MANIFEST_PARSED, this._hlsManifestParsed);
|
|
this._hls.off(HLS.Events.MEDIA_ATTACHED, this._hlsMediaAttached);
|
|
this._hls.destroy();
|
|
this._hls = null;
|
|
}
|
|
try {
|
|
yield this.pause();
|
|
}
|
|
catch (err) {
|
|
this._log.info('Error during pause', err);
|
|
}
|
|
if (this._audio) {
|
|
this._audio.removeEventListener('loadeddata', this._handleMediaPlaying);
|
|
this._audio.removeEventListener('stalled', this._handleAudioError);
|
|
this._audio.removeEventListener('error', this._handleAudioError);
|
|
this._audio = null;
|
|
}
|
|
if (this._video) {
|
|
this._video.removeEventListener('loadeddata', this._handleMediaPlaying);
|
|
this._video.removeEventListener('error', this._handleVideoError);
|
|
this._video = null;
|
|
}
|
|
});
|
|
}
|
|
destroy() {
|
|
this._listenEvents.hjHeard.off(this._hjHeard);
|
|
this._listenEvents.hjOnly.off(this._listenFinished);
|
|
this._listenEvents.noGlobalMatch.off(this._listenFinished);
|
|
this._listenEvents.nonInterruptingGlobal.off(this._listenFinished);
|
|
this._log = null;
|
|
this._playPromise = null;
|
|
this._playReject = null;
|
|
this._playResolve = null;
|
|
this._promises.cancel();
|
|
this._promises = null;
|
|
this._volumePlugin.removeListener('change', this._volumeChanged);
|
|
}
|
|
stopAndDestroy() {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
try {
|
|
yield this.stop();
|
|
}
|
|
catch (err) {
|
|
this._log.warn('Error when stopping during cleanup', err);
|
|
}
|
|
this.destroy();
|
|
});
|
|
}
|
|
_handleStreamError(err) {
|
|
this._rejectOrEmit(err);
|
|
}
|
|
_streamHLS(uri, config = undefined, failover = false) {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
this._log.debug('_streamHLS called', uri, config);
|
|
yield this.stop();
|
|
this._mode = Mode.HLS;
|
|
this._hlsURI = uri;
|
|
this._hls = new HLS(Object.assign({}, config));
|
|
this._hls.on(HLS.Events.MEDIA_ATTACHED, this._hlsMediaAttached);
|
|
this._hls.on(HLS.Events.MANIFEST_PARSED, this._hlsManifestParsed);
|
|
this._hls.on(HLS.Events.FRAG_PARSING_METADATA, this._hlsFragParsing);
|
|
this._hls.on(HLS.Events.ERROR, this._handleHLSErrors);
|
|
this._video = document.createElement('video');
|
|
this._video.addEventListener('loadeddata', this._handleMediaPlaying);
|
|
this._video.addEventListener('error', this._handleVideoError);
|
|
if (failover) {
|
|
return this._hls.attachMedia(this._video);
|
|
}
|
|
return this._promises.wrap(RadioPlayer._promiseTimeout(this._setStreamPromiseHandlers(() => {
|
|
this._hls.attachMedia(this._video);
|
|
}), STREAM_TIMEOUT));
|
|
});
|
|
}
|
|
_fillMissingSongData(songData) {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
songData = songData || {};
|
|
if (!songData.mediaType || songData.mediaType === MediaType.Music) {
|
|
let artworkUrl = null;
|
|
if (songData.artworkUrl) {
|
|
try {
|
|
const response = yield axios_1.default.head(songData.artworkUrl);
|
|
if (response.status >= 200 && response.status < 300) {
|
|
artworkUrl = songData.artworkUrl;
|
|
}
|
|
}
|
|
catch (err) {
|
|
artworkUrl = null;
|
|
}
|
|
}
|
|
if (!songData.artist || !artworkUrl || !songData.title) {
|
|
this._log.info('Stream data was missing some fields; fetching from sideband');
|
|
this._log.info('Stream data', songData);
|
|
const sidebandData = yield this._getSidebandData();
|
|
if (sidebandData) {
|
|
this._log.info('Sideband data', sidebandData);
|
|
if (!artworkUrl && sidebandData.artworkUrl) {
|
|
songData.artworkUrl = sidebandData.artworkUrl;
|
|
}
|
|
if (!songData.artist && sidebandData.artist) {
|
|
songData.artist = sidebandData.artist;
|
|
}
|
|
if (!songData.title && sidebandData.artist) {
|
|
songData.title = sidebandData.title;
|
|
}
|
|
this._log.info('Combined data', songData);
|
|
}
|
|
}
|
|
else {
|
|
this._log.info('No sideband data found');
|
|
}
|
|
}
|
|
return songData;
|
|
});
|
|
}
|
|
_emitSongData(songData, fromSideband = false) {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
const firstTime = this._lastSongData === undefined;
|
|
if (!this._log
|
|
|| !songData && !this._lastSongData
|
|
|| !firstTime && !RadioPlayer._songDataDifferent(songData, this._lastSongData)) {
|
|
return;
|
|
}
|
|
this._lastSongData = songData;
|
|
this._songDataSession = new jibo_cai_utils_1.CancelTokenSession();
|
|
let filledSongData = songData;
|
|
if (!fromSideband) {
|
|
try {
|
|
filledSongData = yield this._songDataSession.wrap(this._fillMissingSongData(songData));
|
|
}
|
|
catch (err) {
|
|
this._log.info('Failed to get sideband data', err);
|
|
}
|
|
}
|
|
if (this._mode === Mode.HLS && this._video && !firstTime) {
|
|
const end = this._video.buffered.length
|
|
? this._video.buffered.end(this._video.buffered.length - 1)
|
|
: 0;
|
|
const current = this._video.currentTime;
|
|
const buffer = end - current;
|
|
const emitIn = end === 0 ? 0 : buffer + 5;
|
|
this._log.debug(`end: ${end}, current: ${current}, buffer: ${buffer}`);
|
|
this._log.debug(`Will emit songData in ${emitIn} seconds`, filledSongData);
|
|
const emitData = () => {
|
|
this.emit('song-data', filledSongData);
|
|
this._songDataTimers.shift();
|
|
};
|
|
this._songDataTimers.push(setTimeout(this._promises.wrapCallback(emitData), emitIn * 1000));
|
|
}
|
|
else {
|
|
this.emit('song-data', filledSongData);
|
|
}
|
|
});
|
|
}
|
|
_streamShoutcast(uri, failover = false) {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
this._log.debug(`Streaming Shoutcast from ${uri}; failover ${failover}`);
|
|
yield this.stop();
|
|
this._mode = Mode.Shoutcast;
|
|
try {
|
|
const hostname = url.parse(uri).hostname;
|
|
yield this._promises.wrap(RadioPlayer._promiseTimeout(promisify(cb => dns.resolve(hostname, cb)), DNS_TIMEOUT));
|
|
}
|
|
catch (err) {
|
|
this._log.info('Shoutcast server hostname failed to resolve', err);
|
|
return failover
|
|
? this._handleAudioError()
|
|
: this._setStreamPromiseHandlers(setImmediate(() => this._handleAudioError()));
|
|
}
|
|
this._icecastClient = yield this._promises.wrap(promisify(cb => icecast.get(uri, cb), false));
|
|
this._icecastClient.on('metadata', (songData) => this._emitSongData(this._processShoutcastMetadata(icecast.parse(songData))));
|
|
this._icecastClient.on('error', this._handleAudioError);
|
|
this._shoutcastServer = http.createServer((req, res) => {
|
|
res.setHeader('Cache-Control', 'no-cache, no-store');
|
|
res.setHeader('Connection', 'close');
|
|
res.setHeader('Content-Type', 'audio/aac');
|
|
res.setHeader('Expires', 'Mon, 26 Jul 1997 05:00:00 GMT');
|
|
res.setHeader('Pragma', 'no-cache');
|
|
this._icecastClient.pipe(res);
|
|
});
|
|
this._shoutcastServer.listen(0, '127.0.0.1');
|
|
yield promisify(cb => this._shoutcastServer.once('listening', cb));
|
|
if (!this._shoutcastServer) {
|
|
return;
|
|
}
|
|
const port = this._shoutcastServer.address().port;
|
|
this._log.debug(`Shoutcast server listening on ${port}`);
|
|
this._audio = document.createElement('audio');
|
|
this._audio.addEventListener('loadeddata', this._handleMediaPlaying);
|
|
this._audio.addEventListener('stalled', this._handleAudioError);
|
|
this._audio.addEventListener('error', this._handleAudioError);
|
|
this._audio.src = `http://127.0.0.1:${port}/`;
|
|
this._volume = this._volumePlugin.currentVolume;
|
|
if (failover) {
|
|
return this.resume();
|
|
}
|
|
return this._promises.wrap(RadioPlayer._promiseTimeout(this._setStreamPromiseHandlers(() => this.resume()), STREAM_TIMEOUT));
|
|
});
|
|
}
|
|
_streamPLS(uri, failover = false) {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
this._log.debug(`Streaming PLS from ${uri}`);
|
|
yield this.stop();
|
|
this._mode = Mode.PLS;
|
|
try {
|
|
const plsContent = (yield this._promises.wrap(axios_1.default.get(uri))).data;
|
|
this._plsStreams = plsContent.split('\n')
|
|
.filter(url => url.startsWith('File'))
|
|
.map(url => url.split('=', 2)[1]);
|
|
this._plsStreamFirstIndex = this._plsStreamIndex =
|
|
Math.floor(Math.random() * this._plsStreams.length);
|
|
}
|
|
catch (err) {
|
|
return this._handleAudioError();
|
|
}
|
|
this._audio = document.createElement('audio');
|
|
this._audio.addEventListener('loadeddata', this._handleMediaPlaying);
|
|
this._audio.addEventListener('stalled', this._handleAudioError);
|
|
this._audio.addEventListener('error', this._handleAudioError);
|
|
this._volume = this._volumePlugin.currentVolume;
|
|
if (failover) {
|
|
return this.resume();
|
|
}
|
|
return this._promises.wrap(RadioPlayer._promiseTimeout(this._setStreamPromiseHandlers(() => this._playPLSStream()), STREAM_TIMEOUT));
|
|
});
|
|
}
|
|
_streamStarted() { }
|
|
setCurrentlySpeaking(isCurrentlySpeaking) {
|
|
this._isCurrentlySpeaking = isCurrentlySpeaking;
|
|
this._volume = isCurrentlySpeaking ? HJ_VOLUME : this._volumePlugin.currentVolume;
|
|
}
|
|
get _volume() {
|
|
return this._audio ? this._audio.volume / VOLUME_NORMAL
|
|
: this._video ? this._video.volume / VOLUME_NORMAL
|
|
: 0;
|
|
}
|
|
set _volume(value) {
|
|
if (value < 1 || value > 10) {
|
|
throw new Error(`Value must be between 1 and 10, inclusive`);
|
|
}
|
|
const volume = value * VOLUME_NORMAL;
|
|
if (this._audio) {
|
|
this._audio.volume = volume;
|
|
}
|
|
if (this._video) {
|
|
this._video.volume = volume;
|
|
}
|
|
}
|
|
_playPLSStream() {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
this._audio.src = this._plsStreams[this._plsStreamIndex];
|
|
yield this.resume();
|
|
const getAndEmitData = () => __awaiter(this, void 0, void 0, function* () {
|
|
const data = yield this._getSidebandData();
|
|
yield this._emitSongData(data, true);
|
|
setTimeout(getAndEmitData, 1000);
|
|
});
|
|
getAndEmitData();
|
|
});
|
|
}
|
|
_nextPLSStream(data) {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
const oldIndex = this._plsStreamIndex;
|
|
if (!this._plsStreams) {
|
|
return this._handleStreamError(data);
|
|
}
|
|
if (++this._plsStreamIndex === this._plsStreams.length) {
|
|
this._plsStreamIndex = 0;
|
|
}
|
|
if (this._plsStreamIndex !== this._plsStreamFirstIndex) {
|
|
this._log.info(`PLS stream ${oldIndex} failed`);
|
|
yield this._playPLSStream();
|
|
}
|
|
else {
|
|
this._handleStreamError(data);
|
|
}
|
|
});
|
|
}
|
|
static _getMediaError(code) {
|
|
switch (code) {
|
|
case 1: return 'MEDIA_ERR_ABORTED';
|
|
case 2: return 'MEDIA_ERR_NETWORK';
|
|
case 3: return 'MEDIA_ERR_DECODE';
|
|
case 4: return 'MEDIA_ERR_SRC_NOT_SUPPORTED';
|
|
default: return 'UNKNOWN';
|
|
}
|
|
}
|
|
_setStreamPromiseHandlers(startStream) {
|
|
if (this._playPromise) {
|
|
startStream();
|
|
return this._playPromise;
|
|
}
|
|
return this._playPromise = this._promises.wrap(new Promise((resolve, reject) => {
|
|
this._playReject = reject;
|
|
this._playResolve = resolve;
|
|
startStream();
|
|
}));
|
|
}
|
|
_resolveStreamPromise() {
|
|
if (this._playPromise) {
|
|
this._log.info('Resolving play promise');
|
|
this._playResolve();
|
|
this._playPromise = null;
|
|
this._playReject = null;
|
|
this._playResolve = null;
|
|
}
|
|
}
|
|
_rejectOrEmit(reason) {
|
|
if (this._playPromise) {
|
|
this._log.info('Rejecting play promise');
|
|
this._playReject(reason);
|
|
this._playPromise = null;
|
|
this._playReject = null;
|
|
this._playResolve = null;
|
|
}
|
|
else {
|
|
this.emit('error', `Error while playing stream: ${reason}`);
|
|
}
|
|
}
|
|
}
|
|
exports.default = RadioPlayer;
|
|
|
|
},{"axios":undefined,"crypto":undefined,"dns":undefined,"events":undefined,"hls.js":undefined,"http":undefined,"icecast":undefined,"jibo-cai-utils":undefined,"jibo-log":undefined,"url":undefined}],2:[function(require,module,exports){
|
|
"use strict";
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.IHeartGenres = {
|
|
Alternative: 1,
|
|
ChristianAndGospel: 2,
|
|
ClassicRock: 3,
|
|
Classical: 4,
|
|
Country: 5,
|
|
HipHopAndRAndB: 6,
|
|
HipHop: 6,
|
|
Jazz: 7,
|
|
MixAndVariety: 8,
|
|
NewsAndTalk: 9,
|
|
Oldies: 10,
|
|
ReggaeAndIsland: 11,
|
|
Reggae: 11,
|
|
Rock: 12,
|
|
SoftRock: 13,
|
|
Spanish: 14,
|
|
Sports: 15,
|
|
Top40AndPop: 16,
|
|
Pop: 16,
|
|
World: 17,
|
|
EightiesAndNinetiesHits: 18,
|
|
Comedy: 19,
|
|
Dance: 77,
|
|
PublicRadio: 93,
|
|
NPR: 93,
|
|
HostsAndDJs: 95,
|
|
TrafficWeatherAndNews: 96,
|
|
Holiday: 97,
|
|
CollegeRadio: 98,
|
|
Mexico: 100,
|
|
Personalities: 101,
|
|
PopularArtists: 102,
|
|
EDM: 103,
|
|
RAndB: 104,
|
|
KidsAndFamily: 106,
|
|
Punk: 107,
|
|
Blues: 108,
|
|
International: 110,
|
|
Metal: 1191,
|
|
Latin: 1192,
|
|
};
|
|
|
|
},{}],3:[function(require,module,exports){
|
|
"use strict";
|
|
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
return new (P || (P = Promise))(function (resolve, reject) {
|
|
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); }
|
|
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
});
|
|
};
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
const axios_1 = require("axios");
|
|
const IHeartInterfaces_1 = require("./IHeartInterfaces");
|
|
const RadioPlayer_1 = require("../RadioPlayer");
|
|
const querystring = require("querystring");
|
|
const CLIENT_VERSION = '1.0.0';
|
|
const DEFAULT_NUM_STATIONS_PER_GENRE = 20;
|
|
const DEVICE_NAME = 'Jibo';
|
|
const INITIAL_HOSTNAME = 'jibo.appliance';
|
|
const INIT_ID = 8169;
|
|
const NATIONAL_DIGITAL = 575;
|
|
const PLAYER = 'iHeartRadioJibo';
|
|
const GET_LOCATION_URI = `https://global.api.iheart.com/api/v3/locationConfig?hostname=${INITIAL_HOSTNAME}&version=1.0.0`;
|
|
const ENDPOINTS = Object.freeze({
|
|
clientConfig: 'v1/bootstrap/getClientConfig',
|
|
countries: 'v2/content/countries',
|
|
login: 'v1/account/loginOrCreateOauthUser',
|
|
states: 'v2/content/states',
|
|
cities: 'v2/content/cities',
|
|
markets: 'v2/content/markets',
|
|
genres: 'v2/content/liveStationGenres',
|
|
liveStations: 'v2/content/liveStations',
|
|
metadata: 'v1/liveMetaData/getStationTrack',
|
|
reportStreamStarted: 'v1/liveRadio/reportStreamStarted',
|
|
});
|
|
const DEFAULT_OPTIONS = Object.freeze({
|
|
responseType: 'json',
|
|
});
|
|
const HLS_CONFIG = Object.freeze({
|
|
maxBufferLength: 10,
|
|
maxMaxBufferLength: 33,
|
|
});
|
|
class IHeartPlayer extends RadioPlayer_1.default {
|
|
constructor() {
|
|
super(...arguments);
|
|
this._stationCache = {};
|
|
this._getSidebandData = () => __awaiter(this, void 0, void 0, function* () {
|
|
if (!this._iHeartLog) {
|
|
return;
|
|
}
|
|
const result = yield this._promises.wrap(this._getSongData({ stationId: this._station.id }));
|
|
const metadata = result.trackRestLiteValues
|
|
&& result.trackRestLiteValues.length
|
|
&& result.trackRestLiteValues[0];
|
|
if (!metadata) {
|
|
return null;
|
|
}
|
|
const fullLength = metadata.trackDuration || 0;
|
|
const minutes = Math.floor(fullLength / 60);
|
|
const seconds = fullLength - minutes * 60;
|
|
const artworkUrl = !metadata.imagePath || metadata.imagePath.includes('imscale')
|
|
? null
|
|
: metadata.imagePath;
|
|
return {
|
|
artist: metadata.artistName,
|
|
title: metadata.title,
|
|
length: `${minutes}:${seconds < 10 ? `0${seconds}` : seconds}`,
|
|
artworkUrl,
|
|
};
|
|
});
|
|
}
|
|
static _getMediaType(fromIHeart) {
|
|
switch (fromIHeart) {
|
|
case 'M': return RadioPlayer_1.MediaType.Music;
|
|
case 'T': return RadioPlayer_1.MediaType.Talk;
|
|
case 'O': return RadioPlayer_1.MediaType.Other;
|
|
default: return null;
|
|
}
|
|
}
|
|
init(serialNumber, releaseVersion, country, lat, lng, listenEvents, volumePlugin) {
|
|
const _super = name => super[name];
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
yield _super("init").call(this, serialNumber, releaseVersion, country, lat, lng, listenEvents, volumePlugin);
|
|
this._iHeartLog = this._log.createChild('iHeart');
|
|
this._locationConfigPromise = this._promises.wrap(this._getLocation().catch(err => this._locationConfigError = err));
|
|
this._initPromise = this._promises.wrap(this._init().catch(err => this._initError = err));
|
|
});
|
|
}
|
|
getCountry() {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
yield this._locationConfigPromise;
|
|
if (this._locationConfigError) {
|
|
throw this._locationConfigError;
|
|
}
|
|
return this._countryCode;
|
|
});
|
|
}
|
|
getStations(options = {}) {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
yield this._initPromise;
|
|
if (this._initError) {
|
|
throw this._initError;
|
|
}
|
|
const limit = options.limit || DEFAULT_NUM_STATIONS_PER_GENRE;
|
|
const genreId = IHeartInterfaces_1.IHeartGenres[options.genreName];
|
|
let stations = yield this._promises.wrap(this._getLiveStations(Object.assign({ genreId }, (options.locality === RadioPlayer_1.Locality.Local
|
|
? { lat: this._lat, lng: this._lng }
|
|
: { marketId: NATIONAL_DIGITAL }), { limit })));
|
|
stations = stations.sort((left, right) => {
|
|
if (!left.streams || !right.streams) {
|
|
return left.streams ? -1 : right.streams ? 1 : 0;
|
|
}
|
|
const leftScore = left.streams.hls_stream ? 2 :
|
|
left.streams.shoutcast_stream ? 1 :
|
|
0;
|
|
const rightScore = right.streams.hls_stream ? 2 :
|
|
right.streams.shoutcast_stream ? 1 :
|
|
0;
|
|
return rightScore - leftScore;
|
|
});
|
|
if (options.preferredStation) {
|
|
const station = stations.find(station => station.callLetters === options.preferredStation);
|
|
if (station) {
|
|
stations = stations.sort((left, right) => {
|
|
const leftScore = left === station ? 1 : 0;
|
|
const rightScore = right === station ? 1 : 0;
|
|
return rightScore - leftScore;
|
|
});
|
|
}
|
|
else {
|
|
const gottenStations = yield this._promises.wrap(this._getLiveStations({
|
|
callLetters: options.preferredStation,
|
|
}));
|
|
if (gottenStations && gottenStations.length) {
|
|
stations = [gottenStations[0], ...stations.slice(0, limit - 1)];
|
|
}
|
|
}
|
|
}
|
|
stations.forEach((station) => {
|
|
this._stationCache[station.callLetters] = station;
|
|
});
|
|
return stations.map((station) => {
|
|
return {
|
|
band: station.band,
|
|
callLetters: station.callLetters,
|
|
description: station.description,
|
|
frequency: station.freq,
|
|
logoUrl: station.logo,
|
|
name: station.name,
|
|
website: station.website,
|
|
};
|
|
});
|
|
});
|
|
}
|
|
play(callLetters) {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
this._iHeartLog.info(`Playing ${callLetters}`);
|
|
yield this._initPromise;
|
|
if (this._initError) {
|
|
throw this._initError;
|
|
}
|
|
const station = this._stationCache[callLetters];
|
|
if (station) {
|
|
this._station = station;
|
|
}
|
|
else {
|
|
const stations = yield this._promises.wrap(this._getLiveStations({
|
|
callLetters,
|
|
limit: 1,
|
|
}));
|
|
if (!stations.length) {
|
|
throw new Error(`Station ${callLetters} not found`);
|
|
}
|
|
this._station =
|
|
this._stationCache[this._station.callLetters] =
|
|
stations[0];
|
|
}
|
|
return this._promises.wrap(this._streamStation());
|
|
});
|
|
}
|
|
resizeArtwork(uri, width) {
|
|
const normalized = Math.min(width, 1920);
|
|
return `http://img.iheart.com/sca/imscale?w=${normalized}&img=${uri}`;
|
|
}
|
|
destroy() {
|
|
super.destroy();
|
|
this._initError = null;
|
|
this._initPromise = null;
|
|
this._locationConfigError = null;
|
|
this._locationConfigPromise = null;
|
|
this._session = null;
|
|
this._station = null;
|
|
this._stationCache = null;
|
|
}
|
|
_processHLSMetadata(titleField) {
|
|
if (titleField.match(/comment=".*"/)
|
|
|| titleField.match(/text=\\?"Spot Block\\?"/)
|
|
|| titleField.match(/text=\\?"Spot Block End\\?"/)) {
|
|
return { mediaType: RadioPlayer_1.MediaType.Talk };
|
|
}
|
|
const mediaTypeMatch = titleField.match(/song_spot=\\"([MTO])\\"/);
|
|
const mediaType = IHeartPlayer._getMediaType(mediaTypeMatch && mediaTypeMatch[1]);
|
|
const titleMatch = titleField.match(/title="(.*?)"/);
|
|
const title = titleMatch && titleMatch[1];
|
|
const artistMatch = titleField.match(/artist="(.*?)"/);
|
|
const artistRaw = artistMatch && artistMatch[1];
|
|
const artist = artistRaw ? artistRaw.split('|')[0].trim() : null;
|
|
const lengthMatch = titleField.match(/length=\\"([0-9:]+?)\\"/);
|
|
const length = lengthMatch && lengthMatch[1];
|
|
const artworkUrlMatch = titleField.match(/amgArtworkURL=\\"(.*?)\\"/);
|
|
const artworkUrlRaw = artworkUrlMatch && artworkUrlMatch[1];
|
|
const artworkUrl = !artworkUrlRaw || artworkUrlRaw.includes('imscale')
|
|
? null
|
|
: artworkUrlRaw;
|
|
return { mediaType, artist, title, length, artworkUrl };
|
|
}
|
|
_processShoutcastMetadata(metadata) {
|
|
this._iHeartLog.debug('Processing Shoutcast metadata', metadata);
|
|
if (!metadata.StreamTitle) {
|
|
return {};
|
|
}
|
|
const [artist, rawTitle] = metadata.StreamTitle.split(' - ');
|
|
const title = rawTitle === 'text' ? '' : rawTitle;
|
|
return { artist, title };
|
|
}
|
|
_handleStreamError(data) {
|
|
const streams = this._station.streams;
|
|
if (this._mode === RadioPlayer_1.Mode.HLS && streams.shoutcast_stream) {
|
|
this._iHeartLog.info('HLS stream failed; trying Shoutcast');
|
|
this._streamShoutcast(streams.shoutcast_stream, true);
|
|
}
|
|
else if ((this._mode === RadioPlayer_1.Mode.HLS || this._mode === RadioPlayer_1.Mode.Shoutcast)
|
|
&& streams.pls_stream) {
|
|
this._iHeartLog.info('HLS and Shoutcast failed; trying PLS');
|
|
this._streamPLS(streams.pls_stream, true);
|
|
}
|
|
else {
|
|
this._iHeartLog.info('All streams failed; emitting error');
|
|
super._handleStreamError(data);
|
|
}
|
|
}
|
|
_streamStarted() {
|
|
if (!this._session) {
|
|
this._iHeartLog.info('No active session when trying to report start of stream');
|
|
return;
|
|
}
|
|
const params = new URLSearchParams();
|
|
params.append('profileId', this._session.profileId.toString());
|
|
params.append('sessionId', this._session.sessionId);
|
|
params.append('playedFrom', '300');
|
|
params.append('host', this._iHeartHostname);
|
|
params.append('parentId', this._station.id.toString());
|
|
try {
|
|
this._promises.wrap(axios_1.default.post(this._getURI(ENDPOINTS.reportStreamStarted), params));
|
|
}
|
|
catch (err) {
|
|
this._iHeartLog.info('Error reporting start of stream', err);
|
|
}
|
|
}
|
|
_init() {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
yield this._locationConfigPromise;
|
|
if (this._locationConfigError) {
|
|
throw this._locationConfigError;
|
|
}
|
|
yield this._promises.wrap(this._login());
|
|
});
|
|
}
|
|
_getLocation() {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
const result = yield this._promises.wrap(axios_1.default.get(GET_LOCATION_URI));
|
|
this._countryCode = result.data.countryCode.toLowerCase();
|
|
this._iHeartBaseURI = result.data.config.apiUrl;
|
|
this._iHeartHostname = result.data.config.hostName;
|
|
this._iHeartTerminal = result.data.config.terminalId;
|
|
});
|
|
}
|
|
_getURI(endpoint) {
|
|
return `${this._iHeartBaseURI}/api/${endpoint}`;
|
|
}
|
|
_login() {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
const params = new URLSearchParams();
|
|
params.append('accessToken', 'anon');
|
|
params.append('accessTokenType', 'anon');
|
|
params.append('deviceId', this._deviceId);
|
|
params.append('deviceName', `anon${this._deviceId}`);
|
|
params.append('host', this._iHeartHostname);
|
|
params.append('oauthUuid', this._deviceId);
|
|
params.append('oauthoverride', 'false');
|
|
this._session = yield this._promises.wrap(this._post(ENDPOINTS.login, params));
|
|
this._iHeartLog.debug('login completed', this._session);
|
|
});
|
|
}
|
|
_get(endpoint, options = undefined) {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
options = Object.assign({}, options);
|
|
options.headers = Object.assign({}, options.headers, { 'X-hostName': this._iHeartHostname });
|
|
const result = yield axios_1.default.get(this._getURI(endpoint), Object.assign({}, DEFAULT_OPTIONS, options));
|
|
return result.data;
|
|
});
|
|
}
|
|
_getHits(endpoint, options = undefined) {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
const result = yield this._promises.wrap(this._get(endpoint, options));
|
|
return result.hits;
|
|
});
|
|
}
|
|
_post(endpoint, data = undefined, options = undefined) {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
options = Object.assign({}, options);
|
|
options.headers = Object.assign({}, options.headers, { 'X-hostName': this._iHeartHostname });
|
|
const result = yield this._promises.wrap(axios_1.default.post(this._getURI(endpoint), data, Object.assign({}, DEFAULT_OPTIONS, options)));
|
|
return result.data;
|
|
});
|
|
}
|
|
_getLiveStations(params) {
|
|
return this._promises.wrap(this._getHits(ENDPOINTS.liveStations, { params }));
|
|
}
|
|
_getSongData(params) {
|
|
return this._promises.wrap(this._get(ENDPOINTS.metadata, { params }));
|
|
}
|
|
_streamStation() {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
const streams = this._station.streams;
|
|
if (streams.secure_hls_stream) {
|
|
return this._promises.wrap(this._iHeartStreamHLS(streams.secure_hls_stream));
|
|
}
|
|
else if (streams.hls_stream) {
|
|
return this._promises.wrap(this._iHeartStreamHLS(streams.hls_stream));
|
|
}
|
|
else if (streams.shoutcast_stream) {
|
|
return this._promises.wrap(this._streamShoutcast(streams.shoutcast_stream));
|
|
}
|
|
else if (streams.pls_stream) {
|
|
return this._promises.wrap(this._streamPLS(streams.pls_stream));
|
|
}
|
|
throw new Error('No compatible stream found');
|
|
});
|
|
}
|
|
_iHeartStreamHLS(stream) {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
const qs = querystring.stringify({
|
|
iheartradioversion: CLIENT_VERSION,
|
|
Deviceid: this._deviceId,
|
|
clienttype: DEVICE_NAME,
|
|
osVersion: this._releaseVersion,
|
|
devicename: DEVICE_NAME,
|
|
callLetters: this._station.callLetters,
|
|
Streamid: this._station.id,
|
|
terminalid: this._iHeartTerminal,
|
|
init_id: INIT_ID,
|
|
fb_broadcast: 0,
|
|
at: 0,
|
|
pname: DEVICE_NAME,
|
|
listenerId: this._deviceId,
|
|
amsparams: `playerid:${PLAYER};skey=${new Date().toISOString()}`,
|
|
});
|
|
const uri = `${stream}?${qs}`;
|
|
return this._promises.wrap(this._streamHLS(uri, HLS_CONFIG));
|
|
});
|
|
}
|
|
}
|
|
exports.default = IHeartPlayer;
|
|
|
|
},{"../RadioPlayer":1,"./IHeartInterfaces":2,"axios":undefined,"querystring":undefined}],4:[function(require,module,exports){
|
|
"use strict";
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
const IHeartPlayer_1 = require("./IHeartPlayer");
|
|
exports.IHeartPlayer = IHeartPlayer_1.default;
|
|
|
|
},{"./IHeartPlayer":3}],5:[function(require,module,exports){
|
|
"use strict";
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
const iHeart_1 = require("./iHeart");
|
|
const RadioPlayer_1 = require("./RadioPlayer");
|
|
exports.RadioPlayer = RadioPlayer_1.default;
|
|
exports.Locality = RadioPlayer_1.Locality;
|
|
exports.MediaType = RadioPlayer_1.MediaType;
|
|
function createRadio() {
|
|
return new iHeart_1.IHeartPlayer();
|
|
}
|
|
exports.createRadio = createRadio;
|
|
|
|
},{"./RadioPlayer":1,"./iHeart":4}]},{},[5])(5)
|
|
});
|
|
|
|
//# sourceMappingURL=jibo-radio.js.map
|