From 3408c7a0c2ee35fc9a47c37b94b9ed789314f4cc Mon Sep 17 00:00:00 2001 From: Max Isom Date: Sun, 15 Mar 2020 14:36:59 -0500 Subject: [PATCH] Use manager instances for guild services --- package.json | 3 +- src/commands/clear.ts | 10 +-- src/commands/play.ts | 21 ++++--- src/commands/queue.ts | 10 +-- src/commands/seek.ts | 10 +-- src/commands/shuffle.ts | 14 ++--- src/inversify.config.ts | 12 ++-- src/managers/player.ts | 29 +++++++++ src/managers/queue.ts | 23 +++++++ src/services/player.ts | 114 ++++++++++++++++++----------------- src/services/queue.ts | 84 +++++++------------------- src/types.ts | 8 +-- src/utils/loading-message.ts | 2 +- 13 files changed, 178 insertions(+), 162 deletions(-) create mode 100644 src/managers/player.ts create mode 100644 src/managers/queue.ts diff --git a/package.json b/package.json index 240cf63..808fa87 100644 --- a/package.json +++ b/package.json @@ -54,8 +54,7 @@ "rules": { "new-cap": "off", "@typescript-eslint/no-unused-vars": "off", - "@typescript-eslint/no-unused-vars-experimental": "error", - "@typescript-eslint/no-inferrable-types": "off" + "@typescript-eslint/no-unused-vars-experimental": "error" } }, "husky": { diff --git a/src/commands/clear.ts b/src/commands/clear.ts index 98f7b7f..4c0539f 100644 --- a/src/commands/clear.ts +++ b/src/commands/clear.ts @@ -1,21 +1,21 @@ import {Message} from 'discord.js'; import {TYPES} from '../types'; import {inject, injectable} from 'inversify'; -import Queue from '../services/queue'; +import QueueManager from '../managers/queue'; import Command from '.'; @injectable() export default class implements Command { public name = 'clear'; public description = 'clears all songs in queue (except currently playing)'; - private readonly queue: Queue; + private readonly queueManager: QueueManager; - constructor(@inject(TYPES.Services.Queue) queue: Queue) { - this.queue = queue; + constructor(@inject(TYPES.Managers.Queue) queueManager: QueueManager) { + this.queueManager = queueManager; } public async execute(msg: Message, _: string []): Promise { - this.queue.clear(msg.guild!.id); + this.queueManager.get(msg.guild!.id).clear(); await msg.channel.send('cleared'); } diff --git a/src/commands/play.ts b/src/commands/play.ts index d0d77d1..84703da 100644 --- a/src/commands/play.ts +++ b/src/commands/play.ts @@ -9,8 +9,9 @@ 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 {QueuedSong, QueuedPlaylist} from '../services/queue'; +import QueueManager from '../managers/queue'; +import PlayerManager from '../managers/player'; import {getMostPopularVoiceChannel} from '../utils/channels'; import LoadingMessage from '../utils/loading-message'; import Command from '.'; @@ -19,15 +20,15 @@ import Command from '.'; export default class implements Command { public name = 'play'; public description = 'plays a song'; - private readonly queue: Queue; - private readonly player: Player; + private readonly queueManager: QueueManager; + private readonly playerManager: PlayerManager; private readonly youtube: YouTube; private readonly youtubeKey: string; private readonly spotify: Spotify; - 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; + 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) { + this.queueManager = queueManager; + this.playerManager = playerManager; this.youtube = youtube; this.youtubeKey = youtubeKey; this.spotify = spotify; @@ -208,7 +209,7 @@ export default class implements Command { return; } - newSongs.forEach(song => this.queue.add(msg.guild!.id, song)); + newSongs.forEach(song => this.queueManager.get(msg.guild!.id).add(song)); // TODO: better response await res.stop('song(s) queued'); @@ -216,8 +217,8 @@ export default class implements Command { const channel = getMostPopularVoiceChannel(msg.guild!); // TODO: don't connect if already connected. - await this.player.connect(msg.guild!.id, channel); + await this.playerManager.get(msg.guild!.id).connect(channel); - await this.player.play(msg.guild!.id); + await this.playerManager.get(msg.guild!.id).play(); } } diff --git a/src/commands/queue.ts b/src/commands/queue.ts index b3a88d9..dfa48d4 100644 --- a/src/commands/queue.ts +++ b/src/commands/queue.ts @@ -1,21 +1,21 @@ import {Message} from 'discord.js'; import {TYPES} from '../types'; import {inject, injectable} from 'inversify'; -import Queue from '../services/queue'; +import QueueManager from '../managers/queue'; import Command from '.'; @injectable() export default class implements Command { public name = 'queue'; public description = 'shows current queue'; - private readonly queue: Queue; + private readonly queueManager: QueueManager; - constructor(@inject(TYPES.Services.Queue) queue: Queue) { - this.queue = queue; + constructor(@inject(TYPES.Managers.Queue) queueManager: QueueManager) { + this.queueManager = queueManager; } public async execute(msg: Message, _: string []): Promise { - const queue = this.queue.get(msg.guild!.id); + const queue = this.queueManager.get(msg.guild!.id).get(); await msg.channel.send('`' + JSON.stringify(queue.slice(0, 10)) + '`'); } diff --git a/src/commands/seek.ts b/src/commands/seek.ts index 2ad3dda..ac8609c 100644 --- a/src/commands/seek.ts +++ b/src/commands/seek.ts @@ -1,7 +1,7 @@ import {Message, TextChannel} from 'discord.js'; import {TYPES} from '../types'; import {inject, injectable} from 'inversify'; -import Player from '../services/player'; +import PlayerManager from '../managers/player'; import LoadingMessage from '../utils/loading-message'; import Command from '.'; @@ -9,10 +9,10 @@ import Command from '.'; export default class implements Command { public name = 'seek'; public description = 'seeks position in currently playing song'; - private readonly player: Player; + private readonly playerManager: PlayerManager; - constructor(@inject(TYPES.Services.Player) player: Player) { - this.player = player; + constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager) { + this.playerManager = playerManager; } public async execute(msg: Message, args: string []): Promise { @@ -31,7 +31,7 @@ export default class implements Command { await loading.start(); try { - await this.player.seek(msg.guild!.id, seekTime); + await this.playerManager.get(msg.guild!.id).seek(seekTime); await loading.stop('seeked'); } catch (_) { diff --git a/src/commands/shuffle.ts b/src/commands/shuffle.ts index 4769f50..e91b695 100644 --- a/src/commands/shuffle.ts +++ b/src/commands/shuffle.ts @@ -1,29 +1,29 @@ import {Message} from 'discord.js'; import {TYPES} from '../types'; import {inject, injectable} from 'inversify'; -import Queue from '../services/queue'; +import QueueManager from '../managers/queue'; import Command from '.'; @injectable() export default class implements Command { public name = 'shuffle'; public description = 'shuffle current queue'; - private readonly queue: Queue; + private readonly queueManager: QueueManager; - constructor(@inject(TYPES.Services.Queue) queue: Queue) { - this.queue = queue; + constructor(@inject(TYPES.Managers.Queue) queueManager: QueueManager) { + this.queueManager = queueManager; } public async execute(msg: Message, _: string []): Promise { - const queue = this.queue.get(msg.guild!.id); + const queue = this.queueManager.get(msg.guild!.id).get(); if (queue.length <= 2) { await msg.channel.send('error: not enough songs to shuffle'); return; } - this.queue.shuffle(msg.guild!.id); + this.queueManager.get(msg.guild!.id).shuffle(); - await msg.channel.send('`' + JSON.stringify(this.queue.get(msg.guild!.id).slice(0, 10)) + '`'); + await msg.channel.send('`' + JSON.stringify(this.queueManager.get(msg.guild!.id).get().slice(0, 10)) + '`'); } } diff --git a/src/inversify.config.ts b/src/inversify.config.ts index e538183..4095859 100644 --- a/src/inversify.config.ts +++ b/src/inversify.config.ts @@ -15,9 +15,9 @@ import { CACHE_DIR } from './utils/config'; -// Services -import Queue from './services/queue'; -import Player from './services/player'; +// Managers +import PlayerManager from './managers/player'; +import QueueManager from './managers/queue'; // Comands import Command from './commands'; @@ -34,9 +34,9 @@ let container = new Container(); container.bind(TYPES.Bot).to(Bot).inSingletonScope(); container.bind(TYPES.Client).toConstantValue(new Client()); -// Services -container.bind(TYPES.Services.Player).to(Player).inSingletonScope(); -container.bind(TYPES.Services.Queue).to(Queue).inSingletonScope(); +// Managers +container.bind(TYPES.Managers.Player).to(PlayerManager).inSingletonScope(); +container.bind(TYPES.Managers.Queue).to(QueueManager).inSingletonScope(); // Commands container.bind(TYPES.Command).to(Clear).inSingletonScope(); diff --git a/src/managers/player.ts b/src/managers/player.ts new file mode 100644 index 0000000..e8bdf76 --- /dev/null +++ b/src/managers/player.ts @@ -0,0 +1,29 @@ +import {inject, injectable} from 'inversify'; +import {TYPES} from '../types'; +import Player from '../services/player'; +import QueueManager from './queue'; + +@injectable() +export default class { + private readonly guildPlayers: Map; + private readonly cacheDir: string; + private readonly queueManager: QueueManager; + + constructor(@inject(TYPES.Config.CACHE_DIR) cacheDir: string, @inject(TYPES.Managers.Queue) queueManager: QueueManager) { + this.guildPlayers = new Map(); + this.cacheDir = cacheDir; + this.queueManager = queueManager; + } + + get(guildId: string): Player { + let player = this.guildPlayers.get(guildId); + + if (!player) { + player = new Player(this.queueManager.get(guildId), this.cacheDir); + + this.guildPlayers.set(guildId, player); + } + + return player; + } +} diff --git a/src/managers/queue.ts b/src/managers/queue.ts new file mode 100644 index 0000000..6c12232 --- /dev/null +++ b/src/managers/queue.ts @@ -0,0 +1,23 @@ +import {injectable} from 'inversify'; +import Queue from '../services/queue'; + +@injectable() +export default class { + private readonly guildQueues: Map; + + constructor() { + this.guildQueues = new Map(); + } + + get(guildId: string): Queue { + let queue = this.guildQueues.get(guildId); + + if (!queue) { + queue = new Queue(); + + this.guildQueues.set(guildId, queue); + } + + return queue; + } +} diff --git a/src/services/player.ts b/src/services/player.ts index 88457ce..346b10a 100644 --- a/src/services/player.ts +++ b/src/services/player.ts @@ -1,5 +1,4 @@ -import {inject, injectable} from 'inversify'; -import {VoiceConnection, VoiceChannel} from 'discord.js'; +import {VoiceConnection, VoiceChannel, StreamDispatcher} from 'discord.js'; import {promises as fs, createWriteStream} from 'fs'; import {Readable, PassThrough} from 'stream'; import path from 'path'; @@ -7,60 +6,44 @@ import hasha from 'hasha'; import ytdl from 'ytdl-core'; import {WriteStream} from 'fs-capacitor'; import ffmpeg from 'fluent-ffmpeg'; -import {TYPES} from '../types'; import Queue, {QueuedSong} from './queue'; -export enum Status { - Playing, - Paused, - Disconnected +export enum STATUS { + PLAYING, + PAUSED, + DISCONNECTED } -export interface GuildPlayer { - status: Status; - voiceConnection: VoiceConnection | null; -} - -@injectable() export default class { - private readonly guildPlayers = new Map(); + public status = STATUS.DISCONNECTED; private readonly queue: Queue; private readonly cacheDir: string; + private voiceConnection: VoiceConnection | null = null; + private dispatcher: StreamDispatcher | null = null; - constructor(@inject(TYPES.Services.Queue) queue: Queue, @inject(TYPES.Config.CACHE_DIR) cacheDir: string) { + constructor(queue: Queue, cacheDir: string) { this.queue = queue; this.cacheDir = cacheDir; } - async connect(guildId: string, channel: VoiceChannel): Promise { - this.initGuild(guildId); - - const guildPlayer = this.guildPlayers.get(guildId); - + async connect(channel: VoiceChannel): Promise { const conn = await channel.join(); - guildPlayer!.voiceConnection = conn; - - this.guildPlayers.set(guildId, guildPlayer!); + this.voiceConnection = conn; } - disconnect(guildId: string): void { - this.initGuild(guildId); - - const guildPlayer = this.guildPlayers.get(guildId); - - if (guildPlayer?.voiceConnection) { - guildPlayer.voiceConnection.disconnect(); + disconnect(): void { + if (this.voiceConnection) { + this.voiceConnection.disconnect(); } } - async seek(guildId: string, positionSeconds: number): Promise { - const guildPlayer = this.get(guildId); - if (guildPlayer.voiceConnection === null) { + async seek(positionSeconds: number): Promise { + if (this.voiceConnection === null) { throw new Error('Not connected to a voice channel.'); } - const currentSong = this.getCurrentSong(guildId); + const currentSong = this.getCurrentSong(); if (!currentSong) { throw new Error('No song currently playing'); @@ -68,46 +51,52 @@ export default class { await this.waitForCache(currentSong.url); - guildPlayer.voiceConnection.play(this.getCachedPath(currentSong.url), {seek: positionSeconds}); + this.attachListeners(this.voiceConnection.play(this.getCachedPath(currentSong.url), {seek: positionSeconds})); } - async play(guildId: string): Promise { - const guildPlayer = this.get(guildId); - if (guildPlayer.voiceConnection === null) { + async play(): Promise { + if (this.voiceConnection === null) { throw new Error('Not connected to a voice channel.'); } - if (guildPlayer.status === Status.Playing) { - // Already playing, return + // Resume from paused state + if (this.status === STATUS.PAUSED && this.dispatcher) { + this.dispatcher.resume(); + this.status = STATUS.PLAYING; return; } - const currentSong = this.getCurrentSong(guildId); + const currentSong = this.getCurrentSong(); if (!currentSong) { throw new Error('Queue empty.'); } + let dispatcher: StreamDispatcher; + if (await this.isCached(currentSong.url)) { - this.get(guildId).voiceConnection!.play(this.getCachedPath(currentSong.url)); + dispatcher = this.voiceConnection.play(this.getCachedPath(currentSong.url)); } else { const stream = await this.getStream(currentSong.url); - this.get(guildId).voiceConnection!.play(stream, {type: 'webm/opus'}); + dispatcher = this.voiceConnection.play(stream, {type: 'webm/opus'}); } - guildPlayer.status = Status.Playing; + this.attachListeners(dispatcher); - this.guildPlayers.set(guildId, guildPlayer); + this.status = STATUS.PLAYING; + this.dispatcher = dispatcher; } - get(guildId: string): GuildPlayer { - this.initGuild(guildId); + pause(): void { + if (!this.dispatcher || this.status !== STATUS.PLAYING) { + throw new Error('Not currently playing.'); + } - return this.guildPlayers.get(guildId) as GuildPlayer; + this.dispatcher.pause(); } - private getCurrentSong(guildId: string): QueuedSong|null { - const songs = this.queue.get(guildId); + private getCurrentSong(): QueuedSong|null { + const songs = this.queue.get(); if (songs.length === 0) { return null; @@ -116,12 +105,6 @@ export default class { return songs[0]; } - private initGuild(guildId: string): void { - if (!this.guildPlayers.get(guildId)) { - this.guildPlayers.set(guildId, {status: Status.Disconnected, voiceConnection: null}); - } - } - private getCachedPath(url: string): string { const hash = hasha(url); return path.join(this.cacheDir, `${hash}.webm`); @@ -238,4 +221,23 @@ export default class { return capacitor.createReadStream(); } + + private attachListeners(stream: StreamDispatcher): void { + stream.on('speaking', async isSpeaking => { + // Automatically advance queued song at end + if (!isSpeaking && this.status === STATUS.PLAYING) { + if (this.queue.get().length > 0) { + this.queue.forward(); + await this.play(); + } + } + }); + + stream.on('close', () => { + // Remove dispatcher from guild player + this.dispatcher = null; + + // TODO: set voiceConnection null as well? + }); + } } diff --git a/src/services/queue.ts b/src/services/queue.ts index 0b98b8e..f439a5c 100644 --- a/src/services/queue.ts +++ b/src/services/queue.ts @@ -1,4 +1,3 @@ -import {injectable} from 'inversify'; import shuffle from 'array-shuffle'; export interface QueuedPlaylist { @@ -14,62 +13,40 @@ export interface QueuedSong { playlist: QueuedPlaylist | null; } -@injectable() export default class { - private readonly guildQueues = new Map(); - private readonly queuePositions = new Map(); + private queue: QueuedSong[] = []; + private position = 0; - forward(guildId: string): void { - const currentPosition = this.queuePositions.get(guildId); - - if (currentPosition && currentPosition + 1 <= this.size(guildId)) { - this.queuePositions.set(guildId, currentPosition + 1); + forward(): void { + if (this.position + 1 <= this.size()) { + this.position++; } else { throw new Error('No songs in queue to forward to.'); } } - back(guildId: string): void { - const currentPosition = this.queuePositions.get(guildId); - - if (currentPosition && currentPosition - 1 >= 0) { - this.queuePositions.set(guildId, currentPosition - 1); + back(): void { + if (this.position - 1 >= 0) { + this.position--; } else { throw new Error('No songs in queue to go back to.'); } } - get(guildId: string): QueuedSong[] { - const currentPosition = this.queuePositions.get(guildId); - - if (currentPosition === undefined) { - return []; - } - - const guildQueue = this.guildQueues.get(guildId); - - if (!guildQueue) { - throw new Error('Bad state. Queue for guild exists but position does not.'); - } - - return guildQueue.slice(currentPosition); + get(): QueuedSong[] { + return this.queue.slice(this.position); } - add(guildId: string, song: QueuedSong): void { - this.initQueue(guildId); - + add(song: QueuedSong): void { if (song.playlist) { // Add to end of queue - this.guildQueues.set(guildId, [...this.guildQueues.get(guildId)!, song]); - } else if (this.guildQueues.get(guildId)!.length === 0) { - // Queue is currently empty - this.guildQueues.set(guildId, [song]); + this.queue.push(song); } else { // Not from playlist, add immediately let insertAt = 0; // Loop until playlist song - this.guildQueues.get(guildId)!.some(song => { + this.queue.some(song => { if (song.playlist) { return true; } @@ -78,41 +55,26 @@ export default class { return false; }); - this.guildQueues.set(guildId, [...this.guildQueues.get(guildId)!.slice(0, insertAt), song, ...this.guildQueues.get(guildId)!.slice(insertAt)]); + this.queue = [...this.queue.slice(0, insertAt), song, ...this.queue.slice(insertAt)]; } } - shuffle(guildId: string): void { - const queue = this.guildQueues.get(guildId); - - if (!queue) { - throw new Error('Queue doesn\'t exist yet.'); - } - - this.guildQueues.set(guildId, [queue[0], ...shuffle(queue.slice(1))]); + shuffle(): void { + this.queue = [this.queue[0], ...shuffle(this.queue.slice(1))]; } - clear(guildId: string): void { - this.initQueue(guildId); - const queue = this.guildQueues.get(guildId); - + clear(): void { const newQueue = []; - if (queue!.length > 0) { - newQueue.push(queue![0]); + // Don't clear curently playing song + if (this.queue.length > 0) { + newQueue.push(this.queue[0]); } - this.guildQueues.set(guildId, newQueue); + this.queue = newQueue; } - size(guildId: string): number { - return this.get(guildId).length; - } - - private initQueue(guildId: string): void { - if (!this.guildQueues.get(guildId)) { - this.guildQueues.set(guildId, []); - this.queuePositions.set(guildId, 0); - } + size(): number { + return this.queue.length; } } diff --git a/src/types.ts b/src/types.ts index 6a9a8b4..9d12fa7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,12 +9,12 @@ export const TYPES = { CACHE_DIR: Symbol('CACHE_DIR') }, Command: Symbol('Command'), - Services: { - Player: Symbol('Player'), - Queue: Symbol('Queue') - }, Lib: { YouTube: Symbol('YouTube'), Spotify: Symbol('Spotify') + }, + Managers: { + Player: Symbol('PlayerManager'), + Queue: Symbol('QueueManager') } }; diff --git a/src/utils/loading-message.ts b/src/utils/loading-message.ts index 3b3d138..9773c03 100644 --- a/src/utils/loading-message.ts +++ b/src/utils/loading-message.ts @@ -5,7 +5,7 @@ export default class { private readonly channel: TextChannel; private readonly text: string; private msg!: Message; - private isStopped: boolean = false; + private isStopped = false; constructor(channel: TextChannel, text: string) { this.channel = channel;