aboutsummaryrefslogtreecommitdiff
path: root/src/services
diff options
context:
space:
mode:
Diffstat (limited to 'src/services')
-rw-r--r--src/services/add-query-to-queue.ts155
-rw-r--r--src/services/config.ts4
-rw-r--r--src/services/get-songs.ts4
-rw-r--r--src/services/natural-language-commands.ts131
-rw-r--r--src/services/player.ts41
5 files changed, 189 insertions, 146 deletions
diff --git a/src/services/add-query-to-queue.ts b/src/services/add-query-to-queue.ts
new file mode 100644
index 0000000..bb512c6
--- /dev/null
+++ b/src/services/add-query-to-queue.ts
@@ -0,0 +1,155 @@
+import {CommandInteraction, GuildMember} from 'discord.js';
+import {inject, injectable} from 'inversify';
+import {Except} from 'type-fest';
+import shuffle from 'array-shuffle';
+import {TYPES} from '../types.js';
+import GetSongs from '../services/get-songs.js';
+import {QueuedSong} from './player.js';
+import PlayerManager from '../managers/player.js';
+import {prisma} from '../utils/db.js';
+import {buildPlayingMessageEmbed} from '../utils/build-embed.js';
+import {getMemberVoiceChannel, getMostPopularVoiceChannel} from '../utils/channels.js';
+
+@injectable()
+export default class AddQueryToQueue {
+ constructor(@inject(TYPES.Services.GetSongs) private readonly getSongs: GetSongs, @inject(TYPES.Managers.Player) private readonly playerManager: PlayerManager) {}
+
+ public async addToQueue({
+ query,
+ addToFrontOfQueue,
+ shuffleAdditions,
+ interaction,
+ }: {
+ query: string;
+ addToFrontOfQueue: boolean;
+ shuffleAdditions: boolean;
+ interaction: CommandInteraction;
+ }): Promise<void> {
+ const guildId = interaction.guild!.id;
+ const player = this.playerManager.get(guildId);
+ const wasPlayingSong = player.getCurrent() !== null;
+
+ const [targetVoiceChannel] = getMemberVoiceChannel(interaction.member as GuildMember) ?? getMostPopularVoiceChannel(interaction.guild!);
+
+ const settings = await prisma.setting.findUnique({where: {guildId}});
+
+ if (!settings) {
+ throw new Error('Could not find settings for guild');
+ }
+
+ const {playlistLimit} = settings;
+
+ await interaction.deferReply();
+
+ let newSongs: Array<Except<QueuedSong, 'addedInChannelId' | 'requestedBy'>> = [];
+ let extraMsg = '';
+
+ // Test if it's a complete URL
+ try {
+ const url = new URL(query);
+
+ const YOUTUBE_HOSTS = [
+ 'www.youtube.com',
+ 'youtu.be',
+ 'youtube.com',
+ 'music.youtube.com',
+ 'www.music.youtube.com',
+ ];
+
+ if (YOUTUBE_HOSTS.includes(url.host)) {
+ // YouTube source
+ if (url.searchParams.get('list')) {
+ // YouTube playlist
+ newSongs.push(...await this.getSongs.youtubePlaylist(url.searchParams.get('list')!));
+ } else {
+ const song = await this.getSongs.youtubeVideo(url.href);
+
+ if (song) {
+ newSongs.push(song);
+ } else {
+ throw new Error('that doesn\'t exist');
+ }
+ }
+ } else if (url.protocol === 'spotify:' || url.host === 'open.spotify.com') {
+ const [convertedSongs, nSongsNotFound, totalSongs] = await this.getSongs.spotifySource(query, playlistLimit);
+
+ if (totalSongs > playlistLimit) {
+ extraMsg = `a random sample of ${playlistLimit} songs was taken`;
+ }
+
+ if (totalSongs > playlistLimit && nSongsNotFound !== 0) {
+ extraMsg += ' and ';
+ }
+
+ if (nSongsNotFound !== 0) {
+ if (nSongsNotFound === 1) {
+ extraMsg += '1 song was not found';
+ } else {
+ extraMsg += `${nSongsNotFound.toString()} songs were not found`;
+ }
+ }
+
+ newSongs.push(...convertedSongs);
+ }
+ } catch (_: unknown) {
+ // Not a URL, must search YouTube
+ const song = await this.getSongs.youtubeVideoSearch(query);
+
+ if (song) {
+ newSongs.push(song);
+ } else {
+ throw new Error('that doesn\'t exist');
+ }
+ }
+
+ if (newSongs.length === 0) {
+ throw new Error('no songs found');
+ }
+
+ if (shuffleAdditions) {
+ newSongs = shuffle(newSongs);
+ }
+
+ newSongs.forEach(song => {
+ player.add({...song, addedInChannelId: interaction.channel!.id, requestedBy: interaction.member!.user.id}, {immediate: addToFrontOfQueue ?? false});
+ });
+
+ const firstSong = newSongs[0];
+
+ let statusMsg = '';
+
+ if (player.voiceConnection === null) {
+ await player.connect(targetVoiceChannel);
+
+ // Resume / start playback
+ await player.play();
+
+ if (wasPlayingSong) {
+ statusMsg = 'resuming playback';
+ }
+
+ await interaction.editReply({
+ embeds: [buildPlayingMessageEmbed(player)],
+ });
+ }
+
+ // Build response message
+ if (statusMsg !== '') {
+ if (extraMsg === '') {
+ extraMsg = statusMsg;
+ } else {
+ extraMsg = `${statusMsg}, ${extraMsg}`;
+ }
+ }
+
+ if (extraMsg !== '') {
+ extraMsg = ` (${extraMsg})`;
+ }
+
+ if (newSongs.length === 1) {
+ await interaction.editReply(`u betcha, **${firstSong.title}** added to the${addToFrontOfQueue ? ' front of the' : ''} queue${extraMsg}`);
+ } else {
+ await interaction.editReply(`u betcha, **${firstSong.title}** and ${newSongs.length - 1} other songs were added to the queue${extraMsg}`);
+ }
+ }
+}
diff --git a/src/services/config.ts b/src/services/config.ts
index 0720c2c..cd56915 100644
--- a/src/services/config.ts
+++ b/src/services/config.ts
@@ -13,6 +13,7 @@ const CONFIG_MAP = {
YOUTUBE_API_KEY: process.env.YOUTUBE_API_KEY,
SPOTIFY_CLIENT_ID: process.env.SPOTIFY_CLIENT_ID,
SPOTIFY_CLIENT_SECRET: process.env.SPOTIFY_CLIENT_SECRET,
+ REGISTER_COMMANDS_ON_BOT: process.env.REGISTER_COMMANDS_ON_BOT === 'true',
DATA_DIR,
CACHE_DIR: path.join(DATA_DIR, 'cache'),
CACHE_LIMIT_IN_BYTES: xbytes.parseSize(process.env.CACHE_LIMIT ?? '2GB'),
@@ -24,6 +25,7 @@ export default class Config {
readonly YOUTUBE_API_KEY!: string;
readonly SPOTIFY_CLIENT_ID!: string;
readonly SPOTIFY_CLIENT_SECRET!: string;
+ readonly REGISTER_COMMANDS_ON_BOT!: boolean;
readonly DATA_DIR!: string;
readonly CACHE_DIR!: string;
readonly CACHE_LIMIT_IN_BYTES!: number;
@@ -39,6 +41,8 @@ export default class Config {
this[key as ConditionalKeys<typeof CONFIG_MAP, number>] = value;
} else if (typeof value === 'string') {
this[key as ConditionalKeys<typeof CONFIG_MAP, string>] = value.trim();
+ } else if (typeof value === 'boolean') {
+ this[key as ConditionalKeys<typeof CONFIG_MAP, boolean>] = value;
} else {
throw new Error(`Unsupported type for ${key}`);
}
diff --git a/src/services/get-songs.ts b/src/services/get-songs.ts
index e132d7b..33c3173 100644
--- a/src/services/get-songs.ts
+++ b/src/services/get-songs.ts
@@ -15,12 +15,10 @@ import {cleanUrl} from '../utils/url.js';
import ThirdParty from './third-party.js';
import Config from './config.js';
import KeyValueCacheProvider from './key-value-cache.js';
+import {ONE_HOUR_IN_SECONDS, ONE_MINUTE_IN_SECONDS} from '../utils/constants.js';
type SongMetadata = Except<QueuedSong, 'addedInChannelId' | 'requestedBy'>;
-const ONE_HOUR_IN_SECONDS = 60 * 60;
-const ONE_MINUTE_IN_SECONDS = 1 * 60;
-
@injectable()
export default class {
private readonly youtube: YouTube;
diff --git a/src/services/natural-language-commands.ts b/src/services/natural-language-commands.ts
deleted file mode 100644
index 4ce8ea3..0000000
--- a/src/services/natural-language-commands.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-import {inject, injectable} from 'inversify';
-import {Message, Guild, GuildMember} from 'discord.js';
-import {TYPES} from '../types.js';
-import PlayerManager from '../managers/player.js';
-import {QueuedSong} from '../services/player.js';
-import {getMostPopularVoiceChannel, getMemberVoiceChannel} from '../utils/channels.js';
-
-@injectable()
-export default class {
- private readonly playerManager: PlayerManager;
-
- constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager) {
- this.playerManager = playerManager;
- }
-
- async execute(msg: Message): Promise<boolean> {
- if (msg.content.startsWith('say') && msg.content.endsWith('muse')) {
- const res = msg.content.slice(3, msg.content.indexOf('muse')).trim();
-
- await msg.channel.send(res);
- return true;
- }
-
- if (msg.content.toLowerCase().includes('packers')) {
- await Promise.all([
- msg.channel.send('GO PACKERS GO!!!'),
- this.playClip(msg.guild!, msg.member!, {
- title: 'GO PACKERS!',
- artist: 'Unknown',
- url: 'https://www.youtube.com/watch?v=qkdtID7mY3E',
- length: 204,
- playlist: null,
- isLive: false,
- addedInChannelId: msg.channel.id,
- thumbnailUrl: null,
- requestedBy: msg.author.id,
- }, 8, 10),
- ]);
-
- return true;
- }
-
- if (msg.content.toLowerCase().includes('bears')) {
- await Promise.all([
- msg.channel.send('F*** THE BEARS'),
- this.playClip(msg.guild!, msg.member!, {
- title: 'GO PACKERS!',
- artist: 'Charlie Berens',
- url: 'https://www.youtube.com/watch?v=UaqlE9Pyy_Q',
- length: 385,
- playlist: null,
- isLive: false,
- addedInChannelId: msg.channel.id,
- thumbnailUrl: null,
- requestedBy: msg.author.id,
- }, 358, 5.5),
- ]);
-
- return true;
- }
-
- if (msg.content.toLowerCase().includes('bitconnect')) {
- await Promise.all([
- msg.channel.send('🌊 🌊 🌊 🌊'),
- this.playClip(msg.guild!, msg.member!, {
- title: 'BITCONNEEECCT',
- artist: 'Carlos Matos',
- url: 'https://www.youtube.com/watch?v=lCcwn6bGUtU',
- length: 227,
- playlist: null,
- isLive: false,
- addedInChannelId: msg.channel.id,
- thumbnailUrl: null,
- requestedBy: msg.author.id,
- }, 50, 13),
- ]);
-
- return true;
- }
-
- return false;
- }
-
- private async playClip(guild: Guild, member: GuildMember, song: QueuedSong, position: number, duration: number): Promise<void> {
- const player = this.playerManager.get(guild.id);
-
- const [channel, n] = getMemberVoiceChannel(member) ?? getMostPopularVoiceChannel(guild);
-
- if (!player.voiceConnection && n === 0) {
- return;
- }
-
- if (!player.voiceConnection) {
- await player.connect(channel);
- }
-
- const isPlaying = player.getCurrent() !== null;
- let oldPosition = 0;
-
- player.add(song, {immediate: true});
-
- if (isPlaying) {
- oldPosition = player.getPosition();
-
- player.manualForward(1);
- }
-
- await player.seek(position);
-
- return new Promise((resolve, reject) => {
- try {
- setTimeout(async () => {
- if (player.getCurrent()?.title === song.title) {
- player.removeCurrent();
-
- if (isPlaying) {
- await player.back();
- await player.seek(oldPosition);
- } else {
- player.disconnect();
- }
- }
-
- resolve();
- }, duration * 1000);
- } catch (error: unknown) {
- reject(error);
- }
- });
- }
-}
diff --git a/src/services/player.ts b/src/services/player.ts
index f46240a..ce611e8 100644
--- a/src/services/player.ts
+++ b/src/services/player.ts
@@ -1,13 +1,13 @@
-import {VoiceChannel, Snowflake, Client, TextChannel} from 'discord.js';
+import {VoiceChannel, Snowflake} from 'discord.js';
import {Readable} from 'stream';
import hasha from 'hasha';
import ytdl from 'ytdl-core';
import {WriteStream} from 'fs-capacitor';
import ffmpeg from 'fluent-ffmpeg';
import shuffle from 'array-shuffle';
-import errorMsg from '../utils/error-msg.js';
import {AudioPlayer, AudioPlayerStatus, createAudioPlayer, createAudioResource, joinVoiceChannel, StreamType, VoiceConnection, VoiceConnectionStatus} from '@discordjs/voice';
import FileCacheProvider from './file-cache.js';
+import debug from '../utils/debug.js';
export interface QueuedPlaylist {
title: string;
@@ -31,9 +31,15 @@ export enum STATUS {
PAUSED,
}
+export interface PlayerEvents {
+ statusChange: (oldStatus: STATUS, newStatus: STATUS) => void;
+}
+
export default class {
- public status = STATUS.PAUSED;
public voiceConnection: VoiceConnection | null = null;
+ public status = STATUS.PAUSED;
+ public guildId: string;
+
private queue: QueuedSong[] = [];
private queuePosition = 0;
private audioPlayer: AudioPlayer | null = null;
@@ -43,12 +49,11 @@ export default class {
private positionInSeconds = 0;
- private readonly discordClient: Client;
private readonly fileCache: FileCacheProvider;
- constructor(client: Client, fileCache: FileCacheProvider) {
- this.discordClient = client;
+ constructor(fileCache: FileCacheProvider, guildId: string) {
this.fileCache = fileCache;
+ this.guildId = guildId;
}
async connect(channel: VoiceChannel): Promise<void> {
@@ -150,9 +155,11 @@ export default class {
},
});
this.voiceConnection.subscribe(this.audioPlayer);
- this.audioPlayer.play(createAudioResource(stream, {
+ const resource = createAudioResource(stream, {
inputType: StreamType.WebmOpus,
- }));
+ });
+
+ this.audioPlayer.play(resource);
this.attachListeners();
@@ -167,14 +174,13 @@ export default class {
this.lastSongURL = currentSong.url;
}
} catch (error: unknown) {
- const currentSong = this.getCurrent();
await this.forward(1);
if ((error as {statusCode: number}).statusCode === 410 && currentSong) {
const channelId = currentSong.addedInChannelId;
if (channelId) {
- await (this.discordClient.channels.cache.get(channelId) as TextChannel).send(errorMsg(`${currentSong.title} is unavailable`));
+ debug(`${currentSong.title} is unavailable`);
return;
}
}
@@ -213,8 +219,12 @@ export default class {
}
}
+ canGoForward(skip: number) {
+ return (this.queuePosition + skip - 1) < this.queue.length;
+ }
+
manualForward(skip: number): void {
- if ((this.queuePosition + skip - 1) < this.queue.length) {
+ if (this.canGoForward(skip)) {
this.queuePosition += skip;
this.positionInSeconds = 0;
this.stopTrackingPosition();
@@ -223,8 +233,12 @@ export default class {
}
}
+ canGoBack() {
+ return this.queuePosition - 1 >= 0;
+ }
+
async back(): Promise<void> {
- if (this.queuePosition - 1 >= 0) {
+ if (this.canGoBack()) {
this.queuePosition--;
this.positionInSeconds = 0;
this.stopTrackingPosition();
@@ -397,6 +411,9 @@ export default class {
.on('error', error => {
console.error(error);
reject(error);
+ })
+ .on('start', command => {
+ debug(`Spawned ffmpeg with ${command as string}`);
});
youtubeStream.pipe(capacitor);