// Copyright 2018 Campbell Crowley. All rights reserved.
// Author: Campbell Crowley (dev@campbellcrowley.com)
const https = require('https');
require('./subModule.js').extend(Spotify);
/**
* @classdesc Attempts to play what a user is playing on Spotify, to a voice
* channel.
* @class
* @augments SubModule
* @listens Command#spotify
*/
function Spotify() {
const self = this;
this.myName = 'Spotify';
let music;
/**
* The request to send to spotify to fetch the currently playing information
* for a user.
*
* @private
* @default
* @constant
* @type {object}
*/
const apiRequest = {
protocol: 'https:',
host: 'api.spotify.com',
path: '/v1/me/player/currently-playing',
method: 'GET',
headers: {
'User-Agent': require('./common.js').ua,
},
};
/** @inheritdoc */
this.initialize = function() {
self.command.on('spotify', commandSpotify, true);
checkMusic();
};
/** @inheritdoc */
this.shutdown = function() {
self.command.deleteEvent('spotify');
for (const i in following) {
if (following[i]) endFollow({guild: {id: i}});
}
};
/** @inheritdoc */
this.unloadable = function() {
return true;
};
/**
* The current users we are monitoring the spotify status of, and some related
* information. Mapped by guild id.
*
* @private
* @type {object}
*/
const following = {};
/**
* Lookup what a user is listening to on Spotify, then attempt to play the
* song in the requester's voice channel.
*
* @private
* @type {commandHandler}
* @param {Discord~Message} msg The message that triggered command.
* @listens Command#spotify
*/
function commandSpotify(msg) {
if (!self.bot.accounts) {
self.common.reply(msg, 'Unable to lookup account information.');
return;
}
let userId = msg.author.id;
if (msg.mentions.users.size > 0) {
userId = msg.mentions.users.first().id;
}
const subCmd = msg.text.trim().split(' ')[0];
let infoOnly = false;
switch (subCmd) {
case 'info':
case 'inf':
case 'playing':
case 'current':
case 'currently':
case 'status':
case 'stats':
case 'stat':
infoOnly = true;
break;
}
if (!infoOnly && music.isSubjugated(msg) && following[msg.guild.id]) {
endFollow(msg);
self.common.reply(msg, 'Stopped following Spotify.', '<@' + userId + '>');
if (following[msg.guild.id] && userId == following[msg.guild.id].user) {
return;
}
}
getCurrentSong(userId, (err, song) => {
if (err) {
if (err == 'Unlinked') {
self.common.reply(
msg,
'Discord account is not linked to Spotify.\nPlease link account' +
' at spikeybot.com to use this command.',
'https://www.spikeybot.com/account/');
} else if (err == 'Bad Response') {
self.common.reply(
msg, 'Unable to get current Spotify status.',
'Bad response from Spotify');
} else if (err == 'Nothing Playing') {
self.common.reply(msg, 'Not listening to anything on Spotify.');
} else {
self.common.reply(msg, 'Unable to get current Spotify status.', err);
}
if (infoOnly || err != 'Nothing Playing') return;
}
if (infoOnly) {
self.common.reply(
msg, 'Song: ' + song.name + '\nArtist: ' + song.artist +
'\nAlbum: ' + song.album + '\nProgress: ' +
Math.round(song.progress / 1000) + ' seconds in.\nCurrently ' +
(song.isPlaying ? 'playing' : 'paused'));
} else {
self.common.reply(
msg,
'Following Spotify music. Music control is now subjugated by ' +
'Spotify.\n(Please wait, seeking may take a while)',
'<@' + userId + '>');
updateFollowingState(msg, userId, song, true);
return;
}
});
}
/**
* Fetch the current playing song on spotify for the given discord user id.
*
* @private
* @param {string|number} userId The Discord user id to lookup.
* @param {Fucntion} cb Callback with err, and data parameters.
*/
function getCurrentSong(userId, cb) {
self.bot.accounts.getSpotifyToken(userId, (res) => {
if (!res) {
cb('Unlinked');
return;
}
const req = https.request(apiRequest, (res) => {
let content = '';
res.on('data', (chunk) => {
content += chunk;
});
res.on('end', () => {
if (res.statusCode == 200) {
let parsed;
try {
parsed = JSON.parse(content);
} catch (err) {
self.error('Failed to parse Spotify response: ' + userId);
console.log(err, content);
cb('Bad Response');
return;
}
if (!parsed.item) {
cb('Nothing Playing');
return;
}
const artists =
(parsed.item.artists || []).map((a) => a.name).join(', ');
const songInfo = {
name: parsed.item.name,
artist: artists,
album: parsed.item.album.name,
progress: parsed.progress_ms,
isPlaying: parsed.is_playing,
duration: parsed.duration_ms,
};
cb(null, songInfo);
} else if (res.statusCode == 204) {
cb('Nothing Playing');
} else {
self.error(
'Unable to fetch spotify currently playing info for user: ' +
userId);
console.error(content);
cb(res.statusCode || 'VERY SCARY ERROR');
}
});
});
req.setHeader('Authorization', 'Bearer ' + res);
req.end();
});
}
/**
* Check on the user's follow state and update the playing status to match.
*
* @private
*
* @param {Discord~Message} msg The message to use as context.
* @param {string|number} userId The discord user id that we are following.
* @param {object} [songInfo] If song info is provided, this will not be
* fetched first. If it is not, the information will be fetched from Spotify
* first.
* @param {boolean} [start=false] Should we setup the player with our settings
* because this is the first run?
*/
function updateFollowingState(msg, userId, songInfo, start = false) {
checkMusic();
if (!start && !music.isSubjugated(msg)) {
endFollow(msg);
return;
}
if (!songInfo) {
getCurrentSong(userId, (err, song) => {
if (err) {
if (err == 'Nothing Playing') {
if (!following[msg.guild.id]) {
following[msg.guild.id] = {};
}
following[msg.guild.id].timeout = setTimeout(
() => updateFollowingState(msg, userId, null, true), 3000);
} else {
self.error(err);
}
return;
}
songInfo = song;
makeTimeout();
});
} else {
makeTimeout();
}
/**
* Start playing the music, and create a timeout to check the status, or for
* the next song.
*
* @private
*/
function makeTimeout() {
if (!start && !music.isSubjugated(msg)) {
endFollow(msg);
return;
}
music.clearQueue(msg);
if (start) {
music.subjugate(msg);
}
if (songInfo && (start || songInfo.progress < 60000)) {
startMusic(msg, songInfo);
}
if (following[msg.guild.id] && following[msg.guild.id].timeout) {
clearTimeout(following[msg.guild.id].timeout);
}
following[msg.guild.id] = {
user: userId,
song: songInfo,
lastUpdate: Date.now(),
};
if (!songInfo || songInfo.duration) {
const delay = songInfo ? (songInfo.duration - songInfo.progress) : 3000;
following[msg.guild.id].timeout =
setTimeout(() => updateFollowingState(msg, userId), delay);
} else {
following[msg.guild.id].timeout =
setTimeout(() => updateDuration(msg, userId), 1000);
}
}
}
/**
* Fetch the song's length from music because Spotify was unable to provide it
* for us.
*
* @private
*
* @param {Discord~Message} msg The context.
* @param {string|number} userId The user id we are following.
*/
function updateDuration(msg, userId) {
checkMusic();
if (following[msg.guild.id] && following[msg.guild.id].timeout) {
clearTimeout(following[msg.guild.id].timeout);
}
if (!music.isSubjugated(msg)) {
endFollow(msg);
return;
}
const dur = music.getDuration(msg);
const prog = music.getProgress(msg);
if (dur != null && prog != null) {
following[msg.guild.id].song.duration = dur * 1000;
const f = following[msg.guild.id];
const delay = f.song.duration - f.song.progress;
following[msg.guild.id].timeout =
setTimeout(() => updateFollowingState(msg, userId), delay);
} else {
following[msg.guild.id].timeout =
setTimeout(() => updateDuration(msg, userId), 1000);
}
}
/**
* Attempt to start playing the given song into a voice channel.
*
* @private
* @param {Discord~Message} msg Message that caused this to happen, and to
* pass into {@link Command} as context.
* @param {{name: string, artist: string, progress: number}} song The current
* song information. Name is song name, progress is progress into the song in
* milliseconds.
*/
function startMusic(msg, song) {
checkMusic();
const seek = Math.round(song.progress / 1000 + (song.progress / 1000 / 5));
music.playSong(msg, song.name + ' by ' + song.artist, seek, true);
}
/**
* Update current reference to music submodule.
*
* @private
*/
function checkMusic() {
if (!music || !music.initialized) {
music = self.bot.getSubmodule('./music.js');
}
}
/**
* Cleanup and delete data in order to stop following user.
*
* @private
* @param {Discord~Message} msg THe context to clear.
*/
function endFollow(msg) {
if (following[msg.guild.id]) {
clearTimeout(following[msg.guild.id].timeout);
}
delete following[msg.guild.id];
checkMusic();
if (music) music.release(msg);
}
}
module.exports = new Spotify();