aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorMax Isom <[email protected]>2020-03-17 21:36:48 -0500
committerMax Isom <[email protected]>2020-03-17 21:36:48 -0500
commit7844e80991d784eed107dd9a661dd5257ae49675 (patch)
treef015b8bb7ca79840235b81f59d97228d8b1d14cf /src
parentc058ec95feacd57eebdb07d4f44469c5c6c4bc01 (diff)
downloadmuse-7844e80991d784eed107dd9a661dd5257ae49675.tar.xz
muse-7844e80991d784eed107dd9a661dd5257ae49675.zip
Refactor play command
Diffstat (limited to 'src')
-rw-r--r--src/commands/play.ts188
-rw-r--r--src/commands/skip.ts1
-rw-r--r--src/inversify.config.ts6
-rw-r--r--src/services/get-songs.ts185
-rw-r--r--src/services/player.ts64
-rw-r--r--src/services/queue.ts2
-rw-r--r--src/types.ts3
-rw-r--r--src/utils/error-msg.ts2
8 files changed, 240 insertions, 211 deletions
diff --git a/src/commands/play.ts b/src/commands/play.ts
index 3eb8f0b..7b9a934 100644
--- a/src/commands/play.ts
+++ b/src/commands/play.ts
@@ -1,15 +1,8 @@
import {TextChannel, Message} from 'discord.js';
-import YouTube from 'youtube.ts';
-import Spotify from 'spotify-web-api-node';
import {URL} from 'url';
-import ytsr from 'ytsr';
-import pLimit from 'p-limit';
-import spotifyURI from 'spotify-uri';
-import got from 'got';
-import {parse, toSeconds} from 'iso8601-duration';
import {TYPES} from '../types';
import {inject, injectable} from 'inversify';
-import {QueuedSong, QueuedPlaylist} from '../services/queue';
+import {QueuedSong} from '../services/queue';
import {STATUS} from '../services/player';
import QueueManager from '../managers/queue';
import PlayerManager from '../managers/player';
@@ -17,6 +10,7 @@ import {getMostPopularVoiceChannel} from '../utils/channels';
import LoadingMessage from '../utils/loading-message';
import errorMsg from '../utils/error-msg';
import Command from '.';
+import GetSongs from '../services/get-songs';
@injectable()
export default class implements Command {
@@ -33,16 +27,12 @@ export default class implements Command {
private readonly queueManager: QueueManager;
private readonly playerManager: PlayerManager;
- private readonly youtube: YouTube;
- private readonly youtubeKey: string;
- private readonly spotify: Spotify;
+ private readonly getSongs: GetSongs;
- constructor(@inject(TYPES.Managers.Queue) queueManager: QueueManager, @inject(TYPES.Managers.Player) playerManager: PlayerManager, @inject(TYPES.Lib.YouTube) youtube: YouTube, @inject(TYPES.Config.YOUTUBE_API_KEY) youtubeKey: string, @inject(TYPES.Lib.Spotify) spotify: Spotify) {
+ constructor(@inject(TYPES.Managers.Queue) queueManager: QueueManager, @inject(TYPES.Managers.Player) playerManager: PlayerManager, @inject(TYPES.Services.GetSongs) getSongs: GetSongs) {
this.queueManager = queueManager;
this.playerManager = playerManager;
- this.youtube = youtube;
- this.youtubeKey = youtubeKey;
- this.spotify = spotify;
+ this.getSongs = getSongs;
}
public async execute(msg: Message, args: string []): Promise<void> {
@@ -79,18 +69,7 @@ export default class implements Command {
const newSongs: QueuedSong[] = [];
- const addSingleSong = async (source: string): Promise<void> => {
- const videoDetails = await this.youtube.videos.get(source);
-
- newSongs.push({
- title: videoDetails.snippet.title,
- artist: videoDetails.snippet.channelTitle,
- length: toSeconds(parse(videoDetails.contentDetails.duration)),
- url: videoDetails.id,
- playlist: null,
- isLive: videoDetails.snippet.liveBroadcastContent === 'live'
- });
- };
+ let nSongsNotFound = 0;
// Test if it's a complete URL
try {
@@ -102,169 +81,48 @@ export default class implements Command {
// YouTube source
if (url.searchParams.get('list')) {
// YouTube playlist
- const playlist = await this.youtube.playlists.get(url.searchParams.get('list') as string);
- const {items} = await this.youtube.playlists.items(url.searchParams.get('list') as string, {maxResults: '50'});
-
- // Unfortunately, package doesn't provide a method for this
- const res: any = await got('https://www.googleapis.com/youtube/v3/videos', {searchParams: {
- part: 'contentDetails',
- id: items.map(item => item.contentDetails.videoId).join(','),
- key: this.youtubeKey
- }}).json();
-
- const queuedPlaylist = {title: playlist.snippet.title, source: playlist.id};
-
- items.forEach(video => {
- const length = toSeconds(parse(res.items.find((i: any) => i.id === video.contentDetails.videoId).contentDetails.duration));
-
- newSongs.push({
- title: video.snippet.title,
- artist: video.snippet.channelTitle,
- length,
- url: video.contentDetails.videoId,
- playlist: queuedPlaylist,
- isLive: false
- });
- });
+ newSongs.push(...await this.getSongs.youtubePlaylist(url.searchParams.get('list') as string));
} else {
// Single video
- try {
- await addSingleSong(url.href);
- } catch (error) {
- await res.stop('that doesn\'t exist');
- return;
- }
- }
- } else if (url.protocol === 'spotify:' || url.host === 'open.spotify.com') {
- // Spotify source
- const parsed = spotifyURI.parse(args[0]);
-
- const tracks: SpotifyApi.TrackObjectSimplified[] = [];
-
- let playlist: QueuedPlaylist | null = null;
-
- switch (parsed.type) {
- case 'album': {
- const uri = parsed as spotifyURI.Album;
-
- const [{body: album}, {body: {items}}] = await Promise.all([this.spotify.getAlbum(uri.id), this.spotify.getAlbumTracks(uri.id, {limit: 50})]);
-
- tracks.push(...items);
-
- playlist = {title: album.name, source: album.href};
- break;
- }
-
- case 'playlist': {
- const uri = parsed as spotifyURI.Playlist;
-
- let [{body: playlistResponse}, {body: tracksResponse}] = await Promise.all([this.spotify.getPlaylist(uri.id), this.spotify.getPlaylistTracks(uri.id, {limit: 1})]);
-
- playlist = {title: playlistResponse.name, source: playlistResponse.href};
-
- tracks.push(...tracksResponse.items.map(playlistItem => playlistItem.track));
-
- while (tracksResponse.next) {
- // eslint-disable-next-line no-await-in-loop
- ({body: tracksResponse} = await this.spotify.getPlaylistTracks(uri.id, {
- limit: parseInt(new URL(tracksResponse.next).searchParams.get('limit') ?? '1', 10),
- offset: parseInt(new URL(tracksResponse.next).searchParams.get('offset') ?? '0', 10)
- }));
+ const song = await this.getSongs.youtubeVideo(url.href);
- tracks.push(...tracksResponse.items.map(playlistItem => playlistItem.track));
- }
-
- break;
- }
-
- case 'track': {
- const uri = parsed as spotifyURI.Track;
-
- const {body} = await this.spotify.getTrack(uri.id);
-
- tracks.push(body);
- break;
- }
-
- case 'artist': {
- // Await res.stop('ope, can\'t add a whole artist');
- const uri = parsed as spotifyURI.Artist;
-
- const {body} = await this.spotify.getArtistTopTracks(uri.id, 'US');
-
- tracks.push(...body.tracks);
- break;
- }
-
- default: {
- await res.stop('huh?');
+ if (song) {
+ newSongs.push(song);
+ } else {
+ await res.stop(errorMsg('that doesn\'t exist'));
return;
}
}
+ } else if (url.protocol === 'spotify:' || url.host === 'open.spotify.com') {
+ const [convertedSongs, nMissing] = await this.getSongs.spotifySource(args[0]);
- // Search YouTube for each track
- const searchForTrack = async (track: SpotifyApi.TrackObjectSimplified): Promise<QueuedSong|null> => {
- try {
- const {items} = await ytsr(`${track.name} ${track.artists[0].name} offical`, {limit: 5});
- const video = items.find((item: { type: string }) => item.type === 'video');
-
- if (!video) {
- throw new Error('No video found for query.');
- }
-
- return {
- title: video.title,
- artist: track.artists[0].name,
- length: track.duration_ms / 1000,
- url: video.link,
- playlist,
- isLive: video.live
- };
- } catch (_) {
- // TODO: handle error
- return null;
- }
- };
-
- // Limit concurrency so hopefully we don't get banned
- const limit = pLimit(3);
- let songs = await Promise.all(tracks.map(async track => limit(async () => searchForTrack(track))));
-
- // Get rid of null values
- songs = songs.reduce((accum: QueuedSong[], song) => {
- if (song) {
- accum.push(song);
- }
-
- return accum;
- }, []);
+ nSongsNotFound = nMissing;
- newSongs.push(...(songs as QueuedSong[]));
+ newSongs.push(...convertedSongs);
}
} catch (_) {
// Not a URL, must search YouTube
const query = args.join(' ');
- try {
- const {items: [video]} = await this.youtube.videos.search({q: query, maxResults: 1, type: 'video'});
+ const song = await this.getSongs.youtubeVideoSearch(query);
- await addSingleSong(video.id.videoId);
- } catch (_) {
- await res.stop('that doesn\'t exist');
+ if (song) {
+ newSongs.push(song);
+ } else {
+ await res.stop(errorMsg('that doesn\'t exist'));
return;
}
}
if (newSongs.length === 0) {
- // TODO: better response
- await res.stop('huh?');
+ await res.stop(errorMsg('no songs found'));
return;
}
newSongs.forEach(song => this.queueManager.get(msg.guild!.id).add(song));
// TODO: better response
- await res.stop('song(s) queued');
+ await res.stop(`song(s) queued (${nSongsNotFound} not found)`);
if (this.playerManager.get(msg.guild!.id).voiceConnection === null) {
await this.playerManager.get(msg.guild!.id).connect(targetVoiceChannel);
diff --git a/src/commands/skip.ts b/src/commands/skip.ts
index 9e5ec4b..3160100 100644
--- a/src/commands/skip.ts
+++ b/src/commands/skip.ts
@@ -34,6 +34,7 @@ export default class implements Command {
await msg.channel.send('keep \'er movin\'');
} catch (_) {
+ console.log(_);
await msg.channel.send('no song to skip to');
}
}
diff --git a/src/inversify.config.ts b/src/inversify.config.ts
index c25d32b..463e08a 100644
--- a/src/inversify.config.ts
+++ b/src/inversify.config.ts
@@ -19,6 +19,9 @@ import {
import PlayerManager from './managers/player';
import QueueManager from './managers/queue';
+// Helpers
+import GetSongs from './services/get-songs';
+
// Comands
import Command from './commands';
import Clear from './commands/clear';
@@ -44,6 +47,9 @@ container.bind<Client>(TYPES.Client).toConstantValue(new Client());
container.bind<PlayerManager>(TYPES.Managers.Player).to(PlayerManager).inSingletonScope();
container.bind<QueueManager>(TYPES.Managers.Queue).to(QueueManager).inSingletonScope();
+// Helpers
+container.bind<GetSongs>(TYPES.Services.GetSongs).to(GetSongs).inSingletonScope();
+
// Commands
container.bind<Command>(TYPES.Command).to(Clear).inSingletonScope();
container.bind<Command>(TYPES.Command).to(Config).inSingletonScope();
diff --git a/src/services/get-songs.ts b/src/services/get-songs.ts
new file mode 100644
index 0000000..19fc6c0
--- /dev/null
+++ b/src/services/get-songs.ts
@@ -0,0 +1,185 @@
+import {URL} from 'url';
+import {inject, injectable} from 'inversify';
+import {toSeconds, parse} from 'iso8601-duration';
+import got from 'got';
+import spotifyURI from 'spotify-uri';
+import Spotify from 'spotify-web-api-node';
+import ytsr from 'ytsr';
+import YouTube from 'youtube.ts';
+import pLimit from 'p-limit';
+import {QueuedSong, QueuedPlaylist} from '../services/queue';
+import {TYPES} from '../types';
+
+@injectable()
+export default class {
+ private readonly youtube: YouTube;
+ private readonly youtubeKey: string;
+ private readonly spotify: Spotify;
+
+ constructor(@inject(TYPES.Lib.YouTube) youtube: YouTube, @inject(TYPES.Config.YOUTUBE_API_KEY) youtubeKey: string, @inject(TYPES.Lib.Spotify) spotify: Spotify) {
+ this.youtube = youtube;
+ this.youtubeKey = youtubeKey;
+ this.spotify = spotify;
+ }
+
+ async youtubeVideoSearch(query: string): Promise<QueuedSong|null> {
+ try {
+ const {items: [video]} = await this.youtube.videos.search({q: query, maxResults: 1, type: 'video'});
+
+ return await this.youtubeVideo(video.id.videoId);
+ } catch (_) {
+ return null;
+ }
+ }
+
+ async youtubeVideo(url: string): Promise<QueuedSong|null> {
+ try {
+ const videoDetails = await this.youtube.videos.get(url);
+
+ return {
+ title: videoDetails.snippet.title,
+ artist: videoDetails.snippet.channelTitle,
+ length: toSeconds(parse(videoDetails.contentDetails.duration)),
+ url: videoDetails.id,
+ playlist: null,
+ isLive: videoDetails.snippet.liveBroadcastContent === 'live'
+ };
+ } catch (_) {
+ return null;
+ }
+ }
+
+ async youtubePlaylist(listId: string): Promise<QueuedSong[]> {
+ // YouTube playlist
+ const playlist = await this.youtube.playlists.get(listId);
+ const {items} = await this.youtube.playlists.items(listId, {maxResults: '50'});
+
+ // Unfortunately, package doesn't provide a method for this
+ const res: any = await got('https://www.googleapis.com/youtube/v3/videos', {searchParams: {
+ part: 'contentDetails',
+ id: items.map(item => item.contentDetails.videoId).join(','),
+ key: this.youtubeKey
+ }}).json();
+
+ const queuedPlaylist = {title: playlist.snippet.title, source: playlist.id};
+
+ return items.map(video => {
+ const length = toSeconds(parse(res.items.find((i: any) => i.id === video.contentDetails.videoId).contentDetails.duration));
+
+ return {
+ title: video.snippet.title,
+ artist: video.snippet.channelTitle,
+ length,
+ url: video.contentDetails.videoId,
+ playlist: queuedPlaylist,
+ isLive: false
+ };
+ });
+ }
+
+ async spotifySource(url: string): Promise<[QueuedSong[], number]> {
+ const parsed = spotifyURI.parse(url);
+
+ const tracks: SpotifyApi.TrackObjectSimplified[] = [];
+
+ let playlist: QueuedPlaylist | null = null;
+
+ switch (parsed.type) {
+ case 'album': {
+ const uri = parsed as spotifyURI.Album;
+
+ const [{body: album}, {body: {items}}] = await Promise.all([this.spotify.getAlbum(uri.id), this.spotify.getAlbumTracks(uri.id, {limit: 50})]);
+
+ tracks.push(...items);
+
+ playlist = {title: album.name, source: album.href};
+ break;
+ }
+
+ case 'playlist': {
+ const uri = parsed as spotifyURI.Playlist;
+
+ let [{body: playlistResponse}, {body: tracksResponse}] = await Promise.all([this.spotify.getPlaylist(uri.id), this.spotify.getPlaylistTracks(uri.id, {limit: 1})]);
+
+ playlist = {title: playlistResponse.name, source: playlistResponse.href};
+
+ tracks.push(...tracksResponse.items.map(playlistItem => playlistItem.track));
+
+ while (tracksResponse.next) {
+ // eslint-disable-next-line no-await-in-loop
+ ({body: tracksResponse} = await this.spotify.getPlaylistTracks(uri.id, {
+ limit: parseInt(new URL(tracksResponse.next).searchParams.get('limit') ?? '1', 10),
+ offset: parseInt(new URL(tracksResponse.next).searchParams.get('offset') ?? '0', 10)
+ }));
+
+ tracks.push(...tracksResponse.items.map(playlistItem => playlistItem.track));
+ }
+
+ break;
+ }
+
+ case 'track': {
+ const uri = parsed as spotifyURI.Track;
+
+ const {body} = await this.spotify.getTrack(uri.id);
+
+ tracks.push(body);
+ break;
+ }
+
+ case 'artist': {
+ const uri = parsed as spotifyURI.Artist;
+
+ const {body} = await this.spotify.getArtistTopTracks(uri.id, 'US');
+
+ tracks.push(...body.tracks);
+ break;
+ }
+
+ default: {
+ return [[], 0];
+ }
+ }
+
+ // Limit concurrency so hopefully we don't get banned for searching
+ const limit = pLimit(3);
+ let songs = await Promise.all(tracks.map(async track => limit(async () => this.spotifyToYouTube(track, playlist))));
+
+ let nSongsNotFound = 0;
+
+ // Get rid of null values
+ songs = songs.reduce((accum: QueuedSong[], song) => {
+ if (song) {
+ accum.push(song);
+ } else {
+ nSongsNotFound++;
+ }
+
+ return accum;
+ }, []);
+
+ return [songs as QueuedSong[], nSongsNotFound];
+ }
+
+ private async spotifyToYouTube(track: SpotifyApi.TrackObjectSimplified, playlist: QueuedPlaylist | null): Promise<QueuedSong | null> {
+ try {
+ const {items} = await ytsr(`${track.name} ${track.artists[0].name} offical`, {limit: 5});
+ const video = items.find((item: { type: string }) => item.type === 'video');
+
+ if (!video) {
+ throw new Error('No video found for query.');
+ }
+
+ return {
+ title: video.title,
+ artist: track.artists[0].name,
+ length: track.duration_ms / 1000,
+ url: video.link,
+ playlist,
+ isLive: video.live
+ };
+ } catch (_) {
+ return null;
+ }
+ }
+}
diff --git a/src/services/player.ts b/src/services/player.ts
index ff14e23..9629547 100644
--- a/src/services/player.ts
+++ b/src/services/player.ts
@@ -64,9 +64,12 @@ export default class {
throw new Error('No song currently playing');
}
- await this.waitForCache(currentSong.url);
-
- this.dispatcher = this.voiceConnection.play(this.getCachedPath(currentSong.url), {seek: positionSeconds});
+ if (await this.isCached(currentSong.url)) {
+ this.dispatcher = this.voiceConnection.play(this.getCachedPath(currentSong.url), {seek: positionSeconds});
+ } else {
+ const stream = await this.getStream(currentSong.url, {seek: positionSeconds});
+ this.dispatcher = this.voiceConnection.play(stream, {type: 'webm/opus'});
+ }
this.attachListeners();
this.startTrackingPosition(positionSeconds);
@@ -147,32 +150,7 @@ export default class {
}
}
- private async waitForCache(url: string, maxRetries = 500, retryDelay = 200): Promise<void> {
- // eslint-disable-next-line no-async-promise-executor
- return new Promise(async (resolve, reject) => {
- if (await this.isCached(url)) {
- resolve();
- } else {
- let nOfChecks = 0;
-
- const cachedCheck = setInterval(async () => {
- if (await this.isCached(url)) {
- clearInterval(cachedCheck);
- resolve();
- } else {
- nOfChecks++;
-
- if (nOfChecks > maxRetries) {
- clearInterval(cachedCheck);
- reject(new Error('Timed out waiting for file to become cached.'));
- }
- }
- }, retryDelay);
- }
- });
- }
-
- private async getStream(url: string): Promise<Readable|string> {
+ private async getStream(url: string, options: {seek?: number} = {}): Promise<Readable|string> {
const cachedPath = this.getCachedPath(url);
if (await this.isCached(url)) {
@@ -187,7 +165,6 @@ export default class {
const filter = (format: ytdl.videoFormat): boolean => format.codecs === 'opus' && format.container === 'webm' && format.audioSampleRate !== undefined && parseInt(format.audioSampleRate, 10) === 48000;
let format = formats.find(filter);
- let canDirectPlay = true;
const nextBestFormat = (formats: ytdl.videoFormat[]): ytdl.videoFormat | undefined => {
if (formats[0].live) {
@@ -204,7 +181,6 @@ export default class {
if (!format) {
format = nextBestFormat(info.formats);
- canDirectPlay = false;
if (!format) {
// If still no format is found, throw
@@ -212,21 +188,21 @@ export default class {
}
}
- let youtubeStream: Readable;
-
- if (canDirectPlay) {
- youtubeStream = ytdl.downloadFromInfo(info, {format});
- } else {
- youtubeStream = ffmpeg(format.url).inputOptions([
- '-reconnect',
- '1',
- '-reconnect_streamed',
- '1',
- '-reconnect_delay_max',
- '5'
- ]).noVideo().audioCodec('libopus').outputFormat('webm').pipe() as PassThrough;
+ const inputOptions = [
+ '-reconnect',
+ '1',
+ '-reconnect_streamed',
+ '1',
+ '-reconnect_delay_max',
+ '5'
+ ];
+
+ if (options.seek) {
+ inputOptions.push('-ss', options.seek.toString());
}
+ const youtubeStream = ffmpeg(format.url).inputOptions(inputOptions).noVideo().audioCodec('libopus').outputFormat('webm').pipe() as PassThrough;
+
const capacitor = new WriteStream();
youtubeStream.pipe(capacitor);
diff --git a/src/services/queue.ts b/src/services/queue.ts
index 1c766f1..eff8a7b 100644
--- a/src/services/queue.ts
+++ b/src/services/queue.ts
@@ -19,7 +19,7 @@ export default class {
private position = 0;
forward(): void {
- if (this.position + 1 <= this.size()) {
+ if (this.position <= this.size() + 1) {
this.position++;
} else {
throw new Error('No songs in queue to forward to.');
diff --git a/src/types.ts b/src/types.ts
index 9d12fa7..0384bc9 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -16,5 +16,8 @@ export const TYPES = {
Managers: {
Player: Symbol('PlayerManager'),
Queue: Symbol('QueueManager')
+ },
+ Services: {
+ GetSongs: Symbol('GetSongs')
}
};
diff --git a/src/utils/error-msg.ts b/src/utils/error-msg.ts
index 7325776..832de3c 100644
--- a/src/utils/error-msg.ts
+++ b/src/utils/error-msg.ts
@@ -5,7 +5,7 @@ export default (error?: string | Error): string => {
if (typeof error === 'string') {
str = `🚫 ${error}`;
} else if (error instanceof Error) {
- str = `🚫 error: ${error.name}`;
+ str = `🚫 ope: ${error.name}`;
}
}