mirror of
https://github.com/BluemediaDev/muse.git
synced 2025-05-08 11:21:37 +02:00
Use IoC, impliment queue
This commit is contained in:
parent
8eb4c8a6c0
commit
17ba78f7b7
17 changed files with 1081 additions and 131 deletions
86
src/bot.ts
Normal file
86
src/bot.ts
Normal file
|
@ -0,0 +1,86 @@
|
|||
import makeDir from 'make-dir';
|
||||
import {Client, Message, Collection} from 'discord.js';
|
||||
import {inject, injectable} from 'inversify';
|
||||
import {TYPES} from './types';
|
||||
import {Settings} from './models';
|
||||
import {sequelize} from './utils/db';
|
||||
import handleGuildCreate from './events/guild-create';
|
||||
import container from './inversify.config';
|
||||
import Command from './commands';
|
||||
|
||||
@injectable()
|
||||
export default class {
|
||||
private readonly client: Client;
|
||||
private readonly token: string;
|
||||
private readonly clientId: string;
|
||||
private readonly dataDir: string;
|
||||
private readonly cacheDir: string;
|
||||
private readonly commands!: Collection<string, Command>;
|
||||
|
||||
constructor(@inject(TYPES.Client) client: Client, @inject(TYPES.Config.DISCORD_TOKEN) token: string, @inject(TYPES.Config.DISCORD_CLIENT_ID) clientId: string, @inject(TYPES.Config.DATA_DIR) dataDir: string, @inject(TYPES.Config.CACHE_DIR) cacheDir: string) {
|
||||
this.client = client;
|
||||
this.token = token;
|
||||
this.clientId = clientId;
|
||||
this.dataDir = dataDir;
|
||||
this.cacheDir = cacheDir;
|
||||
this.commands = new Collection();
|
||||
}
|
||||
|
||||
public async listen(): Promise<string> {
|
||||
// Load in commands
|
||||
container.getAll<Command>(TYPES.Command).forEach(command => {
|
||||
this.commands.set(command.name, command);
|
||||
});
|
||||
|
||||
this.client.on('message', async (msg: Message) => {
|
||||
// Get guild settings
|
||||
if (!msg.guild) {
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await Settings.findByPk(msg.guild.id);
|
||||
|
||||
if (!settings) {
|
||||
// Got into a bad state, send owner welcome message
|
||||
return this.client.emit('guildCreate', msg.guild);
|
||||
}
|
||||
|
||||
const {prefix, channel} = settings;
|
||||
|
||||
if (!msg.content.startsWith(prefix) || msg.author.bot || msg.channel.id !== channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const args = msg.content.slice(prefix.length).split(/ +/);
|
||||
const command = args.shift()!.toLowerCase();
|
||||
|
||||
if (!this.commands.has(command)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const handler = this.commands.get(command);
|
||||
|
||||
handler!.execute(msg, args);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
msg.reply('there was an error trying to execute that command!');
|
||||
}
|
||||
});
|
||||
|
||||
this.client.on('ready', async () => {
|
||||
// Create directory if necessary
|
||||
await makeDir(this.dataDir);
|
||||
await makeDir(this.cacheDir);
|
||||
|
||||
await sequelize.sync({});
|
||||
|
||||
console.log(`Ready! Invite the bot with https://discordapp.com/oauth2/authorize?client_id=${this.clientId}&scope=bot`);
|
||||
});
|
||||
|
||||
// Register event handlers
|
||||
this.client.on('guildCreate', handleGuildCreate);
|
||||
|
||||
return this.client.login(this.token);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
7
src/commands/index.ts
Normal file
7
src/commands/index.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import {Message} from 'discord.js';
|
||||
|
||||
export default interface Command {
|
||||
name: string;
|
||||
description: string;
|
||||
execute: (msg: Message, args: string[]) => Promise<void>;
|
||||
}
|
|
@ -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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public async execute(msg: Message, args: string []): Promise<void> {
|
||||
const newSongs: QueuedSong[] = [];
|
||||
|
||||
const res = new LoadingMessage(msg.channel as TextChannel, 'hold on a sec');
|
||||
await res.start();
|
||||
|
||||
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)
|
||||
}));
|
||||
|
||||
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!);
|
||||
|
||||
const conn = await channel.join();
|
||||
// TODO: don't connect if already connected.
|
||||
await this.player.connect(msg.guild!.id, channel);
|
||||
|
||||
const stream = await getYouTubeStream(url);
|
||||
|
||||
conn.play(stream, {type: 'webm/opus'});
|
||||
await this.player.play(msg.guild!.id);
|
||||
}
|
||||
};
|
||||
|
||||
export default play;
|
||||
}
|
||||
|
|
22
src/commands/queue.ts
Normal file
22
src/commands/queue.ts
Normal file
|
@ -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)) + '`');
|
||||
}
|
||||
}
|
75
src/index.ts
75
src/index.ts
|
@ -1,68 +1,15 @@
|
|||
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, CACHE_DIR} from './utils/config';
|
||||
import {Settings} from './models';
|
||||
import {sequelize} from './utils/db';
|
||||
import {CommandHandler} from './interfaces';
|
||||
import handleGuildCreate from './events/guild-create';
|
||||
import container from './inversify.config';
|
||||
import Spotify from 'spotify-web-api-node';
|
||||
import {TYPES} from './types';
|
||||
import Bot from './bot';
|
||||
|
||||
const client = new Discord.Client();
|
||||
const commands = new Discord.Collection();
|
||||
let bot = container.get<Bot>(TYPES.Bot);
|
||||
const spotify = container.get<Spotify>(TYPES.Lib.Spotify);
|
||||
|
||||
// Load in commands
|
||||
const commandFiles = fs.readdirSync(path.join(__dirname, 'commands')).filter(file => file.endsWith('.js'));
|
||||
(async () => {
|
||||
const auth = await spotify.clientCredentialsGrant();
|
||||
|
||||
for (const file of commandFiles) {
|
||||
const command = require(`./commands/${file}`).default;
|
||||
spotify.setAccessToken(auth.body.access_token);
|
||||
|
||||
commands.set(command.name, command);
|
||||
}
|
||||
|
||||
// Generic message handler
|
||||
client.on('message', async (msg: Discord.Message) => {
|
||||
// Get guild settings
|
||||
const settings = await Settings.findByPk(msg.guild!.id);
|
||||
|
||||
if (!settings) {
|
||||
// Got into a bad state, send owner welcome message
|
||||
return client.emit('guildCreate', msg.guild);
|
||||
}
|
||||
|
||||
const {prefix, channel} = settings;
|
||||
|
||||
if (!msg.content.startsWith(prefix) || msg.author.bot || msg.channel.id !== channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const args = msg.content.slice(prefix.length).split(/ +/);
|
||||
const command = args.shift()!.toLowerCase();
|
||||
|
||||
if (!commands.has(command)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const handler = commands.get(command) as CommandHandler;
|
||||
|
||||
handler.execute(msg, args);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
msg.reply('there was an error trying to execute that command!');
|
||||
}
|
||||
});
|
||||
|
||||
client.on('ready', async () => {
|
||||
// Create directory if necessary
|
||||
await makeDir(DATA_DIR);
|
||||
await makeDir(CACHE_DIR);
|
||||
|
||||
await sequelize.sync({});
|
||||
|
||||
console.log(`Ready! Invite the bot with https://discordapp.com/oauth2/authorize?client_id=${DISCORD_CLIENT_ID}&scope=bot`);
|
||||
});
|
||||
|
||||
client.on('guildCreate', handleGuildCreate);
|
||||
|
||||
client.login(DISCORD_TOKEN);
|
||||
bot.listen();
|
||||
})();
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
import {Message} from 'discord.js';
|
||||
|
||||
export interface CommandHandler {
|
||||
name: string;
|
||||
description: string;
|
||||
execute: (msg: Message, args: string[]) => void;
|
||||
}
|
54
src/inversify.config.ts
Normal file
54
src/inversify.config.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
import 'reflect-metadata';
|
||||
import {Container} from 'inversify';
|
||||
import {TYPES} from './types';
|
||||
import Bot from './bot';
|
||||
import {Client} from 'discord.js';
|
||||
import YouTube from 'youtube.ts';
|
||||
import Spotify from 'spotify-web-api-node';
|
||||
import {
|
||||
DISCORD_TOKEN,
|
||||
DISCORD_CLIENT_ID,
|
||||
YOUTUBE_API_KEY,
|
||||
SPOTIFY_CLIENT_ID,
|
||||
SPOTIFY_CLIENT_SECRET,
|
||||
DATA_DIR,
|
||||
CACHE_DIR
|
||||
} from './utils/config';
|
||||
|
||||
// Services
|
||||
import Queue from './services/queue';
|
||||
import Player from './services/player';
|
||||
|
||||
// Comands
|
||||
import Command from './commands';
|
||||
import Config from './commands/config';
|
||||
import Play from './commands/play';
|
||||
import QueueCommad from './commands/queue';
|
||||
|
||||
let container = new Container();
|
||||
|
||||
// Bot
|
||||
container.bind<Bot>(TYPES.Bot).to(Bot).inSingletonScope();
|
||||
container.bind<Client>(TYPES.Client).toConstantValue(new Client());
|
||||
|
||||
// Services
|
||||
container.bind<Player>(TYPES.Services.Player).to(Player).inSingletonScope();
|
||||
container.bind<Queue>(TYPES.Services.Queue).to(Queue).inSingletonScope();
|
||||
|
||||
// Commands
|
||||
container.bind<Command>(TYPES.Command).to(Config).inSingletonScope();
|
||||
container.bind<Command>(TYPES.Command).to(Play).inSingletonScope();
|
||||
container.bind<Command>(TYPES.Command).to(QueueCommad).inSingletonScope();
|
||||
|
||||
// Config values
|
||||
container.bind<string>(TYPES.Config.DISCORD_TOKEN).toConstantValue(DISCORD_TOKEN);
|
||||
container.bind<string>(TYPES.Config.DISCORD_CLIENT_ID).toConstantValue(DISCORD_CLIENT_ID);
|
||||
container.bind<string>(TYPES.Config.YOUTUBE_API_KEY).toConstantValue(YOUTUBE_API_KEY);
|
||||
container.bind<string>(TYPES.Config.DATA_DIR).toConstantValue(DATA_DIR);
|
||||
container.bind<string>(TYPES.Config.CACHE_DIR).toConstantValue(CACHE_DIR);
|
||||
|
||||
// Static libraries
|
||||
container.bind<YouTube>(TYPES.Lib.YouTube).toConstantValue(new YouTube(YOUTUBE_API_KEY));
|
||||
container.bind<Spotify>(TYPES.Lib.Spotify).toConstantValue(new Spotify({clientId: SPOTIFY_CLIENT_ID, clientSecret: SPOTIFY_CLIENT_SECRET}));
|
||||
|
||||
export default container;
|
1
src/packages.d.ts
vendored
1
src/packages.d.ts
vendored
|
@ -1 +1,2 @@
|
|||
declare module 'node-emoji';
|
||||
declare module 'ytsr';
|
||||
|
|
88
src/services/player.ts
Normal file
88
src/services/player.ts
Normal file
|
@ -0,0 +1,88 @@
|
|||
import {inject, injectable} from 'inversify';
|
||||
import {VoiceConnection, VoiceChannel} from 'discord.js';
|
||||
import {TYPES} from '../types';
|
||||
import Queue from './queue';
|
||||
import getYouTubeStream from '../utils/get-youtube-stream';
|
||||
|
||||
export enum Status {
|
||||
Playing,
|
||||
Paused,
|
||||
Disconnected
|
||||
}
|
||||
|
||||
export interface GuildPlayer {
|
||||
status: Status;
|
||||
voiceConnection: VoiceConnection | null;
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export default class {
|
||||
private readonly guildPlayers = new Map<string, GuildPlayer>();
|
||||
private readonly queue: Queue;
|
||||
|
||||
constructor(@inject(TYPES.Services.Queue) queue: Queue) {
|
||||
this.queue = queue;
|
||||
}
|
||||
|
||||
async connect(guildId: string, channel: VoiceChannel): Promise<void> {
|
||||
this.initGuild(guildId);
|
||||
|
||||
const guildPlayer = this.guildPlayers.get(guildId);
|
||||
|
||||
const conn = await channel.join();
|
||||
|
||||
guildPlayer!.voiceConnection = conn;
|
||||
|
||||
this.guildPlayers.set(guildId, guildPlayer!);
|
||||
}
|
||||
|
||||
disconnect(guildId: string): void {
|
||||
this.initGuild(guildId);
|
||||
|
||||
const guildPlayer = this.guildPlayers.get(guildId);
|
||||
|
||||
if (guildPlayer?.voiceConnection) {
|
||||
guildPlayer.voiceConnection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async play(guildId: string): Promise<void> {
|
||||
const guildPlayer = this.get(guildId);
|
||||
if (guildPlayer.voiceConnection === null) {
|
||||
throw new Error('Not connected to a voice channel.');
|
||||
}
|
||||
|
||||
if (guildPlayer.status === Status.Playing) {
|
||||
// Already playing, return
|
||||
return;
|
||||
}
|
||||
|
||||
const songs = this.queue.get(guildId);
|
||||
|
||||
if (songs.length === 0) {
|
||||
throw new Error('Queue empty.');
|
||||
}
|
||||
|
||||
const song = songs[0];
|
||||
|
||||
const stream = await getYouTubeStream(song.url);
|
||||
|
||||
this.get(guildId).voiceConnection!.play(stream, {type: 'webm/opus'});
|
||||
|
||||
guildPlayer.status = Status.Playing;
|
||||
|
||||
this.guildPlayers.set(guildId, guildPlayer);
|
||||
}
|
||||
|
||||
get(guildId: string): GuildPlayer {
|
||||
this.initGuild(guildId);
|
||||
|
||||
return this.guildPlayers.get(guildId) as GuildPlayer;
|
||||
}
|
||||
|
||||
private initGuild(guildId: string): void {
|
||||
if (!this.guildPlayers.get(guildId)) {
|
||||
this.guildPlayers.set(guildId, {status: Status.Disconnected, voiceConnection: null});
|
||||
}
|
||||
}
|
||||
}
|
89
src/services/queue.ts
Normal file
89
src/services/queue.ts
Normal file
|
@ -0,0 +1,89 @@
|
|||
import {injectable} from 'inversify';
|
||||
|
||||
export interface QueuedPlaylist {
|
||||
title: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface QueuedSong {
|
||||
title: string;
|
||||
url: string;
|
||||
length: number;
|
||||
playlist: QueuedPlaylist | null;
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export default class {
|
||||
private readonly guildQueues = new Map<string, QueuedSong[]>();
|
||||
private readonly queuePositions = new Map<string, number>();
|
||||
|
||||
forward(guildId: string): void {
|
||||
const currentPosition = this.queuePositions.get(guildId);
|
||||
|
||||
if (currentPosition && currentPosition + 1 <= this.size(guildId)) {
|
||||
this.queuePositions.set(guildId, currentPosition + 1);
|
||||
} 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);
|
||||
} 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);
|
||||
}
|
||||
|
||||
add(guildId: string, song: QueuedSong): void {
|
||||
if (!this.guildQueues.get(guildId)) {
|
||||
this.guildQueues.set(guildId, []);
|
||||
this.queuePositions.set(guildId, 0);
|
||||
}
|
||||
|
||||
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]);
|
||||
} else {
|
||||
// Not from playlist, add immediately
|
||||
let insertAt = 0;
|
||||
|
||||
// Loop until playlist song
|
||||
this.guildQueues.get(guildId)!.some(song => {
|
||||
if (song.playlist) {
|
||||
return true;
|
||||
}
|
||||
|
||||
insertAt++;
|
||||
return false;
|
||||
});
|
||||
|
||||
this.guildQueues.set(guildId, [...this.guildQueues.get(guildId)!.slice(0, insertAt), song, ...this.guildQueues.get(guildId)!.slice(insertAt)]);
|
||||
}
|
||||
}
|
||||
|
||||
size(guildId: string): number {
|
||||
return this.get(guildId).length;
|
||||
}
|
||||
}
|
20
src/types.ts
Normal file
20
src/types.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
export const TYPES = {
|
||||
Bot: Symbol('Bot'),
|
||||
Client: Symbol('Client'),
|
||||
Config: {
|
||||
DISCORD_TOKEN: Symbol('DISCORD_TOKEN'),
|
||||
DISCORD_CLIENT_ID: Symbol('DISCORD_CLIENT_ID'),
|
||||
YOUTUBE_API_KEY: Symbol('YOUTUBE_API_KEY'),
|
||||
DATA_DIR: Symbol('DATA_DIR'),
|
||||
CACHE_DIR: Symbol('CACHE_DIR')
|
||||
},
|
||||
Command: Symbol('Command'),
|
||||
Services: {
|
||||
Player: Symbol('Player'),
|
||||
Queue: Symbol('Queue')
|
||||
},
|
||||
Lib: {
|
||||
YouTube: Symbol('YouTube'),
|
||||
Spotify: Symbol('Spotify')
|
||||
}
|
||||
};
|
|
@ -4,5 +4,8 @@ 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 YOUTUBE_API_KEY: string = process.env.YOUTUBE_API_KEY ? process.env.YOUTUBE_API_KEY : '';
|
||||
export const SPOTIFY_CLIENT_ID: string = process.env.SPOTIFY_CLIENT_ID ? process.env.SPOTIFY_CLIENT_ID : '';
|
||||
export const SPOTIFY_CLIENT_SECRET: string = process.env.SPOTIFY_CLIENT_SECRET ? process.env.SPOTIFY_CLIENT_SECRET : '';
|
||||
export const DATA_DIR = path.resolve(process.env.DATA_DIR ? process.env.DATA_DIR : './data');
|
||||
export const CACHE_DIR = path.join(DATA_DIR, 'cache');
|
||||
|
|
|
@ -3,6 +3,7 @@ import {Readable, PassThrough} from 'stream';
|
|||
import path from 'path';
|
||||
import hasha from 'hasha';
|
||||
import ytdl from 'ytdl-core';
|
||||
import prism from 'prism-media';
|
||||
import {CACHE_DIR} from './config';
|
||||
|
||||
const nextBestFormat = (formats: ytdl.videoFormat[]): ytdl.videoFormat => {
|
||||
|
@ -24,9 +25,11 @@ export default async (url: string): Promise<Readable> => {
|
|||
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);
|
||||
let canDirectPlay = true;
|
||||
|
||||
if (!format) {
|
||||
format = nextBestFormat(info.formats);
|
||||
canDirectPlay = false;
|
||||
}
|
||||
|
||||
try {
|
||||
|
@ -46,6 +49,30 @@ export default async (url: string): Promise<Readable> => {
|
|||
await fs.rename(cacheTempPath, cachedPath);
|
||||
});
|
||||
|
||||
return ytdl.downloadFromInfo(info, {format}).pipe(pass);
|
||||
if (canDirectPlay) {
|
||||
return ytdl.downloadFromInfo(info, {format}).pipe(pass);
|
||||
}
|
||||
|
||||
const transcoder = new prism.FFmpeg({
|
||||
args: [
|
||||
'-reconnect',
|
||||
'1',
|
||||
'-reconnect_streamed',
|
||||
'1',
|
||||
'-reconnect_delay_max',
|
||||
'5',
|
||||
'-i',
|
||||
format.url,
|
||||
'-loglevel',
|
||||
'verbose',
|
||||
'-vn',
|
||||
'-acodec',
|
||||
'libopus',
|
||||
'-f',
|
||||
'webm'
|
||||
]
|
||||
});
|
||||
|
||||
return transcoder.pipe(pass);
|
||||
}
|
||||
};
|
||||
|
|
67
src/utils/loading-message.ts
Normal file
67
src/utils/loading-message.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
import {TextChannel, Message} from 'discord.js';
|
||||
import delay from 'delay';
|
||||
|
||||
export default class {
|
||||
private readonly channel: TextChannel;
|
||||
private readonly text: string;
|
||||
private msg!: Message;
|
||||
private isStopped: boolean = false;
|
||||
|
||||
constructor(channel: TextChannel, text: string) {
|
||||
this.channel = channel;
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.msg = await this.channel.send(this.text);
|
||||
|
||||
const period = 500;
|
||||
|
||||
const icons = ['⚪', '🔵', '⚫'];
|
||||
|
||||
const reactions = [];
|
||||
|
||||
let i = 0;
|
||||
let isRemoving = false;
|
||||
(async () => {
|
||||
while (!this.isStopped) {
|
||||
if (reactions.length === icons.length) {
|
||||
isRemoving = true;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await delay(period);
|
||||
|
||||
if (isRemoving) {
|
||||
const reactionToRemove = reactions.shift();
|
||||
|
||||
if (reactionToRemove) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await reactionToRemove.remove();
|
||||
} else {
|
||||
isRemoving = false;
|
||||
}
|
||||
} else {
|
||||
if (!this.isStopped) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
reactions.push(await this.msg.react(icons[i % icons.length]));
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async stop(str?: string): Promise<Message> {
|
||||
this.isStopped = true;
|
||||
|
||||
if (str) {
|
||||
await Promise.all([this.msg.reactions.removeAll(), this.msg.edit(str)]);
|
||||
} else {
|
||||
await this.msg.reactions.removeAll();
|
||||
}
|
||||
|
||||
return this.msg;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue