mirror of
https://github.com/BluemediaDev/muse.git
synced 2025-05-11 20:31:35 +02:00
Migrate to slash commands (#431)
Co-authored-by: Federico fuji97 Rapetti <fuji1097@gmail.com>
This commit is contained in:
parent
e883275d83
commit
56a469a999
51 changed files with 1270 additions and 1294 deletions
155
src/services/add-query-to-queue.ts
Normal file
155
src/services/add-query-to-queue.ts
Normal file
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue