import { fullIDMatcher } from '@squeebot/core/lib/common'; import { logger } from '@squeebot/core/lib/core'; import { Plugin, Configurable, EventListener, DependencyLoad } from '@squeebot/core/lib/plugin'; import { IMessage, MessageResolver } from '@squeebot/core/lib/types'; import { Readable } from 'stream'; import ytdl from 'ytdl-core'; import ytsr, { Video } from 'ytsr'; import { Client, Message, VoiceChannel, VoiceConnection } from 'discord.js'; interface Configuration { control: string[]; voice?: string; leaveAfter?: number; } interface Cached { playing: boolean; config: Configuration; readable: Readable; url: string; connection?: VoiceConnection; queue: string[]; } /* Discord config example: { "control": ["discord/testing/s:/*"], "voice": "", } */ @Configurable({ youtube: true, discord: [], icecast: [], }) class JukeboxPlugin extends Plugin { public cache: Cached[] = []; @EventListener('pluginUnload') public unloadEventHandler(plugin: string | Plugin): void { if (plugin === this.name || plugin === this) { this.emit('pluginUnloaded', this); } } getConfigByMessage(msg: IMessage): Configuration | null { const rm = msg.fullRoomID; if (!rm) { return null; } for (const cfg of this.config.get('discord', []) as Configuration[]) { let roomMatch = false; for (const room of cfg.control) { if (fullIDMatcher(rm, room)) { roomMatch = true; } } if (!roomMatch) { continue; } return cfg; } return null; } getCachedConfig(cfg: Configuration): Cached | null { for (const ch of this.cache) { if (ch.config === cfg) { return ch; } } return null; } async getStream(link: string): Promise { if (link.indexOf('http') !== 0) { const search = await ytsr(link, { limit: 1 }); link = (search.items[0] as Video).link; } return ytdl(link, { quality: 'highestaudio', dlChunkSize: 0 }); } async playToDiscord(discord: Client, cfg: Configuration, link: string, msg: IMessage): Promise { const cached = this.getCachedConfig(cfg); if (cached && cached.readable) { cached.readable.destroy(); } let channel; if (!cfg.voice) { const member = (msg.data as Message).member; if (member && member.voice) { const avc = member.voice.channel; if (avc && avc.joinable) { channel = avc; } } } else { channel = await discord.channels.fetch(cfg.voice as string); } if (!channel || channel.type !== 'voice') { throw new Error('Invalid voice channel.'); } const vc = await (channel as VoiceChannel).join(); const str = await this.getStream(link); vc.play(str).on('finish', () => { str.destroy(); const cacheCurrent = this.getCachedConfig(cfg); if (!cacheCurrent) { vc.disconnect(); return; } cacheCurrent.playing = false; if (!cacheCurrent.queue || !cacheCurrent.queue.length) { vc.disconnect(); cacheCurrent.connection = undefined; return; } const next = cacheCurrent.queue.shift() as string; this.playToDiscord(discord, cfg, next, msg).catch((e) => { logger.error('[%s] Autoplay threw an error:', this.name, e.stack); msg.resolve('Playing of the next track in the queue failed.'); }); }); if (cached) { cached.playing = true; cached.readable = str; cached.url = link; cached.connection = vc; } else { this.cache.push({ playing: true, readable: str, url: link, config: cfg, connection: vc, queue: [], }); } } @DependencyLoad('simplecommands') addCommands(cmd: any): void { const roomsWithJukeboxes = []; for (const cfg of this.config.get('discord', []) as Configuration[]) { roomsWithJukeboxes.push(...cfg.control); } cmd.registerCommand([{ name: 'play', plugin: this.name, execute: async (msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[]): Promise => { if (!simplified[0]) { msg.resolve('Please provide an URL or search term to play!'); return true; } const vcConfig = this.getConfigByMessage(msg); if (!vcConfig) { msg.resolve('This channel has no configuration for jukeboxes.'); return true; } if (msg.source.type !== 'DiscordProtocol') { msg.resolve('Currently, only Discord is supported.'); return true; } try { await this.playToDiscord((msg.source as any).client, vcConfig, simplified.join(' '), msg); } catch (e) { msg.resolve('Something went wrong!'); logger.error('[%s] Threw an error:', this.name, e.stack); } return true; }, source: roomsWithJukeboxes, usage: '', description: 'Play something', }, { name: 'stop', plugin: this.name, execute: async (msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[]): Promise => { const vcConfig = this.getConfigByMessage(msg); if (!vcConfig) { msg.resolve('This channel has no configuration for jukeboxes.'); return true; } if (msg.source.type !== 'DiscordProtocol') { msg.resolve('Currently, only Discord is supported.'); return true; } const cached = this.getCachedConfig(vcConfig); if (cached && cached.playing) { cached.playing = false; cached.readable.destroy(); if (cached.connection) { try { cached.connection.disconnect(); cached.connection = undefined; } catch (e) {} } msg.resolve('Stopped currently playing.'); } return true; }, source: roomsWithJukeboxes, description: 'Stop the currently playing track', }, { name: 'enqueue', plugin: this.name, execute: async (msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[]): Promise => { if (!simplified[0]) { msg.resolve('Please provide an URL or search term to play!'); return true; } const vcConfig = this.getConfigByMessage(msg); if (!vcConfig) { msg.resolve('This channel has no configuration for jukeboxes.'); return true; } if (msg.source.type !== 'DiscordProtocol') { msg.resolve('Currently, only Discord is supported.'); return true; } const track = simplified.join(' '); const cached = this.getCachedConfig(vcConfig); if (cached && cached.playing) { cached.queue.push(track); } else { try { await this.playToDiscord((msg.source as any).client, vcConfig, track, msg); } catch (e) { msg.resolve('Something went wrong!'); logger.error('[%s] Threw an error:', this.name, e.stack); } } return true; }, source: roomsWithJukeboxes, usage: '', description: 'Queue a track', aliases: ['queue'], }, { name: 'skip', plugin: this.name, execute: async (msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[]): Promise => { const vcConfig = this.getConfigByMessage(msg); if (!vcConfig) { msg.resolve('This channel has no configuration for jukeboxes.'); return true; } if (msg.source.type !== 'DiscordProtocol') { msg.resolve('Currently, only Discord is supported.'); return true; } const cached = this.getCachedConfig(vcConfig); if (cached) { if (cached.playing) { cached.readable.destroy(); cached.playing = false; if (cached.connection) { try { cached.connection.disconnect(); cached.connection = undefined; } catch (e) {} } } if (cached.queue.length) { const next = cached.queue.shift() as string; try { await this.playToDiscord((msg.source as any).client, vcConfig, next, msg); } catch (e) { msg.resolve('Something went wrong!'); logger.error('[%s] Threw an error:', this.name, e.stack); } return true; } } msg.resolve('The queue is empty.'); return true; }, aliases: ['next'], source: roomsWithJukeboxes, description: 'Skip the current track', }]); } } module.exports = JukeboxPlugin;