aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorMax Isom <[email protected]>2020-03-10 11:58:09 -0500
committerMax Isom <[email protected]>2020-03-10 11:58:09 -0500
commit8eb4c8a6c06f672cb50efae5ea30215d465000af (patch)
tree938a439e254694b4ed283d71a303b442ba739104 /src
parent652cc2e5efed6ddef593570ee90634cbc1c452bb (diff)
downloadmuse-8eb4c8a6c06f672cb50efae5ea30215d465000af.tar.xz
muse-8eb4c8a6c06f672cb50efae5ea30215d465000af.zip
Basic play functionality
Diffstat (limited to 'src')
-rw-r--r--src/commands/play.ts21
-rw-r--r--src/index.ts3
-rw-r--r--src/utils/channels.ts38
-rw-r--r--src/utils/config.ts1
-rw-r--r--src/utils/get-youtube-stream.ts51
5 files changed, 113 insertions, 1 deletions
diff --git a/src/commands/play.ts b/src/commands/play.ts
new file mode 100644
index 0000000..cf23d60
--- /dev/null
+++ b/src/commands/play.ts
@@ -0,0 +1,21 @@
+import {CommandHandler} from '../interfaces';
+import {getMostPopularVoiceChannel} from '../utils/channels';
+import getYouTubeStream from '../utils/get-youtube-stream';
+
+const play: CommandHandler = {
+ name: 'play',
+ description: 'plays a song',
+ execute: async (msg, args) => {
+ const url = args[0];
+
+ const channel = getMostPopularVoiceChannel(msg.guild!);
+
+ const conn = await channel.join();
+
+ const stream = await getYouTubeStream(url);
+
+ conn.play(stream, {type: 'webm/opus'});
+ }
+};
+
+export default play;
diff --git a/src/index.ts b/src/index.ts
index 6681d45..b4c760f 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,7 +2,7 @@ import fs from 'fs';
import path from 'path';
import makeDir from 'make-dir';
import Discord from 'discord.js';
-import {DISCORD_TOKEN, DISCORD_CLIENT_ID, DATA_DIR} from './utils/config';
+import {DISCORD_TOKEN, DISCORD_CLIENT_ID, DATA_DIR, CACHE_DIR} from './utils/config';
import {Settings} from './models';
import {sequelize} from './utils/db';
import {CommandHandler} from './interfaces';
@@ -56,6 +56,7 @@ client.on('message', async (msg: Discord.Message) => {
client.on('ready', async () => {
// Create directory if necessary
await makeDir(DATA_DIR);
+ await makeDir(CACHE_DIR);
await sequelize.sync({});
diff --git a/src/utils/channels.ts b/src/utils/channels.ts
new file mode 100644
index 0000000..d138c45
--- /dev/null
+++ b/src/utils/channels.ts
@@ -0,0 +1,38 @@
+import {Guild, VoiceChannel} from 'discord.js';
+
+export const getMostPopularVoiceChannel = (guild: Guild, min = 0): VoiceChannel => {
+ interface PopularResult {
+ n: number;
+ channel: VoiceChannel | null;
+ }
+
+ const voiceChannels: PopularResult[] = [];
+
+ for (const [_, channel] of guild.channels.cache) {
+ if (channel.type === 'voice' && channel.members.size >= min) {
+ voiceChannels.push({
+ channel: channel as VoiceChannel,
+ n: channel.members.size
+ });
+ }
+ }
+
+ if (voiceChannels.length === 0) {
+ throw new Error('No voice channels meet minimum size');
+ }
+
+ // Find most popular channel
+ const popularChannel = voiceChannels.reduce((popular: PopularResult, elem: PopularResult) => {
+ if (elem.n > popular.n) {
+ return elem;
+ }
+
+ return popular;
+ }, {n: -1, channel: null});
+
+ if (popularChannel.channel) {
+ return popularChannel.channel;
+ }
+
+ throw new Error();
+};
diff --git a/src/utils/config.ts b/src/utils/config.ts
index db70d7f..0b3b33d 100644
--- a/src/utils/config.ts
+++ b/src/utils/config.ts
@@ -5,3 +5,4 @@ dotenv.config();
export const DISCORD_TOKEN: string = process.env.DISCORD_TOKEN ? process.env.DISCORD_TOKEN : '';
export const DISCORD_CLIENT_ID: string = process.env.DISCORD_CLIENT_ID ? process.env.DISCORD_CLIENT_ID : '';
export const DATA_DIR = path.resolve(process.env.DATA_DIR ? process.env.DATA_DIR : './data');
+export const CACHE_DIR = path.join(DATA_DIR, 'cache');
diff --git a/src/utils/get-youtube-stream.ts b/src/utils/get-youtube-stream.ts
new file mode 100644
index 0000000..41f687c
--- /dev/null
+++ b/src/utils/get-youtube-stream.ts
@@ -0,0 +1,51 @@
+import {promises as fs, createReadStream, createWriteStream} from 'fs';
+import {Readable, PassThrough} from 'stream';
+import path from 'path';
+import hasha from 'hasha';
+import ytdl from 'ytdl-core';
+import {CACHE_DIR} from './config';
+
+const nextBestFormat = (formats: ytdl.videoFormat[]): ytdl.videoFormat => {
+ formats = formats
+ .filter(format => format.averageBitrate)
+ .sort((a, b) => b.averageBitrate - a.averageBitrate);
+ return formats.find(format => !format.bitrate) ?? formats[0];
+};
+
+// TODO: are some videos not available in webm/opus?
+export default async (url: string): Promise<Readable> => {
+ const hash = hasha(url);
+ const cachedPath = path.join(CACHE_DIR, `${hash}.webm`);
+
+ const info = await ytdl.getInfo(url);
+
+ const {formats} = info;
+
+ 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);
+
+ if (!format) {
+ format = nextBestFormat(info.formats);
+ }
+
+ try {
+ // Test if file exists
+ await fs.access(cachedPath);
+
+ // If so, return cached stream
+ return createReadStream(cachedPath);
+ } catch (_) {
+ // Not yet cached, must download
+ const cacheTempPath = path.join('/tmp', `${hash}.webm`);
+ const cacheStream = createWriteStream(cacheTempPath);
+
+ const pass = new PassThrough();
+
+ pass.pipe(cacheStream).on('finish', async () => {
+ await fs.rename(cacheTempPath, cachedPath);
+ });
+
+ return ytdl.downloadFromInfo(info, {format}).pipe(pass);
+ }
+};