diff --git a/jukebox/plugin.json b/jukebox/plugin.json new file mode 100644 index 0000000..67e8b87 --- /dev/null +++ b/jukebox/plugin.json @@ -0,0 +1,9 @@ +{ + "main": "plugin.js", + "name": "jukebox", + "description": "Jukebox plugin for Icecast/Discord", + "version": "1.0.0", + "tags": ["commands", "jukebox", "music"], + "dependencies": ["simplecommands"], + "npmDependencies": ["ytdl-core@^4.1.3","@discordjs/opus@^0.3.3","discord.js@^12.5.1","ytsr@1.0.4"] +} diff --git a/jukebox/plugin.ts b/jukebox/plugin.ts new file mode 100644 index 0000000..e0d2aad --- /dev/null +++ b/jukebox/plugin.ts @@ -0,0 +1,299 @@ +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 } from '@squeebot/core/lib/types'; +import { Readable } from 'stream'; + +import ytdl from 'ytdl-core'; +import ytsr, { Video } from 'ytsr'; + +import { Client, VoiceChannel } from 'discord.js'; + +interface Configuration { + control: string[]; + voice?: string; + leaveAfter?: number; +} + +interface Cached { + playing: boolean; + config: Configuration; + readable: Readable; + url: string; + connection?: any; + queue: string[]; +} + +/* + Discord config example: { + "control": ["discord/testing/s:/*"], + "voice": "", + "leaveAfter": 10, + } + + Icecast config example: { + "control": ["irc/icynet/#music"], + "icecast": { + "server": "http://localhost:8080" + } + } +*/ + +@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(); + } + + const 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); + + str.on('end', () => setTimeout(() => { + str.destroy(); + + const cacheCurrent = this.getCachedConfig(cfg); + if (!cacheCurrent) { + vc.disconnect(); + return; + } + + cacheCurrent.playing = false; + + if (!cacheCurrent.queue || !cacheCurrent.queue.length) { + vc.disconnect(); + 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.'); + }); + }, 5000)); + + if (cached) { + cached.playing = true; + cached.readable = str; + cached.url = link; + } else { + this.cache.push({ + playing: true, + readable: str, + url: link, + config: cfg, + 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, 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, 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(); + msg.resolve('Stopped currently playing.'); + } + return true; + }, + source: roomsWithJukeboxes, + description: 'Stop the currently playing track', + }, + { + name: 'enqueue', + plugin: this.name, + execute: async (msg: IMessage, 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, 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.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; + }, + source: roomsWithJukeboxes, + description: 'Skip the current track', + }]); + } +} + +module.exports = JukeboxPlugin; diff --git a/squeebot.repo.json b/squeebot.repo.json index 7df537c..1fa1c37 100644 --- a/squeebot.repo.json +++ b/squeebot.repo.json @@ -21,6 +21,10 @@ "name": "gamedig", "version": "1.0.0" }, + { + "name": "jukebox", + "version": "1.0.0" + }, { "name": "timezone", "version": "1.0.0"