diff options
| author | Max Isom <[email protected]> | 2020-03-12 22:41:26 -0500 |
|---|---|---|
| committer | Max Isom <[email protected]> | 2020-03-12 22:41:26 -0500 |
| commit | 17ba78f7b7d78c638ab00b9d4af79110130b0bcd (patch) | |
| tree | df0671a4b2845333198b57906b5dde68b709d37a /src/commands | |
| parent | 8eb4c8a6c06f672cb50efae5ea30215d465000af (diff) | |
| download | muse-17ba78f7b7d78c638ab00b9d4af79110130b0bcd.tar.xz muse-17ba78f7b7d78c638ab00b9d4af79110130b0bcd.zip | |
Use IoC, impliment queue
Diffstat (limited to 'src/commands')
| -rw-r--r-- | src/commands/config.ts | 19 | ||||
| -rw-r--r-- | src/commands/index.ts | 7 | ||||
| -rw-r--r-- | src/commands/play.ts | 212 | ||||
| -rw-r--r-- | src/commands/queue.ts | 22 |
4 files changed, 237 insertions, 23 deletions
diff --git a/src/commands/config.ts b/src/commands/config.ts index 80d5727..597578d 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -1,11 +1,14 @@ -import {TextChannel} from 'discord.js'; -import {CommandHandler} from '../interfaces'; +import {TextChannel, Message} from 'discord.js'; +import {injectable} from 'inversify'; import {Settings} from '../models'; +import Command from '.'; -const config: CommandHandler = { - name: 'config', - description: 'Change various bot settings.', - execute: async (msg, args) => { +@injectable() +export default class implements Command { + public name = 'config'; + public description = 'changes various bot settings'; + + public async execute(msg: Message, args: string []): Promise<void> { if (args.length === 0) { // Show current settings const settings = await Settings.findByPk(msg.guild!.id); @@ -58,6 +61,4 @@ const config: CommandHandler = { await msg.channel.send('🚫 I\'ve never met this setting in my life'); } } -}; - -export default config; +} diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 0000000..1a6d686 --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,7 @@ +import {Message} from 'discord.js'; + +export default interface Command { + name: string; + description: string; + execute: (msg: Message, args: string[]) => Promise<void>; +} diff --git a/src/commands/play.ts b/src/commands/play.ts index cf23d60..5a96bb6 100644 --- a/src/commands/play.ts +++ b/src/commands/play.ts @@ -1,21 +1,205 @@ -import {CommandHandler} from '../interfaces'; +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 Queue, {QueuedSong, QueuedPlaylist} from '../services/queue'; +import Player from '../services/player'; import {getMostPopularVoiceChannel} from '../utils/channels'; -import getYouTubeStream from '../utils/get-youtube-stream'; +import LoadingMessage from '../utils/loading-message'; +import Command from '.'; -const play: CommandHandler = { - name: 'play', - description: 'plays a song', - execute: async (msg, args) => { - const url = args[0]; +@injectable() +export default class implements Command { + public name = 'play'; + public description = 'plays a song'; + private readonly queue: Queue; + private readonly player: Player; + private readonly youtube: YouTube; + private readonly youtubeKey: string; + private readonly spotify: Spotify; - const channel = getMostPopularVoiceChannel(msg.guild!); + constructor(@inject(TYPES.Services.Queue) queue: Queue, @inject(TYPES.Services.Player) player: Player, @inject(TYPES.Lib.YouTube) youtube: YouTube, @inject(TYPES.Config.YOUTUBE_API_KEY) youtubeKey: string, @inject(TYPES.Lib.Spotify) spotify: Spotify) { + this.queue = queue; + this.player = player; + this.youtube = youtube; + this.youtubeKey = youtubeKey; + this.spotify = spotify; + } - const conn = await channel.join(); + public async execute(msg: Message, args: string []): Promise<void> { + const newSongs: QueuedSong[] = []; - const stream = await getYouTubeStream(url); + const res = new LoadingMessage(msg.channel as TextChannel, 'hold on a sec'); + await res.start(); - conn.play(stream, {type: 'webm/opus'}); - } -}; + const addSingleSong = async (source: string): Promise<void> => { + const videoDetails = await this.youtube.videos.get(source); + + newSongs.push({title: videoDetails.snippet.title, length: toSeconds(parse(videoDetails.contentDetails.duration)), url: videoDetails.id, playlist: null}); + }; + + // Test if it's a complete URL + try { + const url = new URL(args[0]); + + const YOUTUBE_HOSTS = ['www.youtube.com', 'youtu.be', 'youtube.com']; + + if (YOUTUBE_HOSTS.includes(url.host)) { + // 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, length, url: video.contentDetails.videoId, playlist: queuedPlaylist}); + }); + } 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) + })); -export default play; + 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'); + return; + } + + default: { + await res.stop('huh?'); + return; + } + } + + // Search YouTube for each track + const searchForTrack = async (track: any): Promise<QueuedSong|null> => { + try { + const {items: [video]} = await ytsr(`${track.name as string} ${track.artists[0].name as string} offical`, {limit: 1}); + + return {title: video.title, length: track.duration_ms / 1000, url: video.link, playlist}; + } 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; + }, []); + + newSongs.push(...(songs as QueuedSong[])); + } + } 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'}); + + await addSingleSong(video.id.videoId); + } catch (_) { + await res.stop('that doesn\'t exist'); + return; + } + } + + if (newSongs.length === 0) { + // TODO: better response + await res.stop('huh?'); + return; + } + + newSongs.forEach(song => this.queue.add(msg.guild!.id, song)); + + // TODO: better response + await res.stop('song(s) queued'); + + const channel = getMostPopularVoiceChannel(msg.guild!); + + // TODO: don't connect if already connected. + await this.player.connect(msg.guild!.id, channel); + + await this.player.play(msg.guild!.id); + } +} diff --git a/src/commands/queue.ts b/src/commands/queue.ts new file mode 100644 index 0000000..b3a88d9 --- /dev/null +++ b/src/commands/queue.ts @@ -0,0 +1,22 @@ +import {Message} from 'discord.js'; +import {TYPES} from '../types'; +import {inject, injectable} from 'inversify'; +import Queue from '../services/queue'; +import Command from '.'; + +@injectable() +export default class implements Command { + public name = 'queue'; + public description = 'shows current queue'; + private readonly queue: Queue; + + constructor(@inject(TYPES.Services.Queue) queue: Queue) { + this.queue = queue; + } + + public async execute(msg: Message, _: string []): Promise<void> { + const queue = this.queue.get(msg.guild!.id); + + await msg.channel.send('`' + JSON.stringify(queue.slice(0, 10)) + '`'); + } +} |
