Fix stream command, add reconnect command
This commit is contained in:
parent
0c01c800f6
commit
4fc0d2f2c8
@ -23,6 +23,8 @@ module.exports = {
|
||||
|
||||
'!volume': 'volume',
|
||||
'!vol': 'volume',
|
||||
|
||||
'!reconnect': 'reconnect',
|
||||
},
|
||||
|
||||
/**
|
||||
|
271
index.ts
271
index.ts
@ -9,8 +9,10 @@ import {
|
||||
ChannelType,
|
||||
TextChannel
|
||||
} from 'discord.js';
|
||||
import { joinVoiceChannel, VoiceConnection, createAudioPlayer, createAudioResource, AudioPlayerStatus, PlayerSubscription } from '@discordjs/voice';
|
||||
import { joinVoiceChannel, VoiceConnection, createAudioPlayer, createAudioResource, AudioPlayerStatus, PlayerSubscription, StreamType } from '@discordjs/voice';
|
||||
import * as Duration from 'tinyduration';
|
||||
|
||||
// @ts-ignore - Regardless of what TS says, we cannot import yargs-parser as a default import
|
||||
import * as parser from 'yargs-parser';
|
||||
|
||||
import YTDlpWrap from 'yt-dlp-wrap';
|
||||
@ -18,6 +20,7 @@ import YTDlpWrap from 'yt-dlp-wrap';
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as fsp from 'fs/promises';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
const config = require('./config');
|
||||
|
||||
@ -65,6 +68,8 @@ let currentSong: QueueEntry | null | undefined;
|
||||
let voiceConnection: VoiceConnection;
|
||||
let playerSubscription: PlayerSubscription;
|
||||
let volume = config.defaultVolume;
|
||||
let ffmpegProcess = null;
|
||||
let keepStreamActive = false;
|
||||
|
||||
function logError(message: string | Object)
|
||||
{
|
||||
@ -128,12 +133,68 @@ function getSongText(entry: QueueEntry | undefined = currentSong, withUrl: boole
|
||||
return formatSongText(info, withUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill the FFmpeg process, if it exists
|
||||
*/
|
||||
function killFfmpeg()
|
||||
{
|
||||
if (ffmpegProcess) {
|
||||
ffmpegProcess.kill('SIGKILL');
|
||||
ffmpegProcess = null;
|
||||
log('Killed FFmpeg process');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a new voice connection
|
||||
* If a connection already exists, return that instead.
|
||||
*
|
||||
* @param {boolean} destroy - Destroy the current connection, if there is one. Will always create a new connection if this is set to true. Default: false
|
||||
* @returns Promise<VoiceConnection | null>
|
||||
*/
|
||||
async function newVoiceConnection(destroy: boolean = false) : Promise<VoiceConnection | null>
|
||||
{
|
||||
if (voiceConnection) {
|
||||
if (!destroy) {
|
||||
return voiceConnection;
|
||||
}
|
||||
|
||||
voiceConnection.destroy();
|
||||
voiceConnection = null;
|
||||
}
|
||||
|
||||
const channel = await client.channels.fetch(config.voiceChannel) as VoiceChannel;
|
||||
if (!channel) {
|
||||
logError(`Failed to find channel ${config.voiceChannel}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
voiceConnection = joinVoiceChannel({
|
||||
channelId: channel.id,
|
||||
guildId: channel.guild.id,
|
||||
adapterCreator: channel.guild.voiceAdapterCreator,
|
||||
});
|
||||
|
||||
if (!voiceConnection) {
|
||||
logError(`Failed to join channel ${config.voiceChannel}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return voiceConnection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip the currently playing song (if any)
|
||||
* Play the next song in the queue (if any)
|
||||
*/
|
||||
async function playNext(message: Message | null = null, words: string[] = [])
|
||||
{
|
||||
if (currentSong && currentSong.isStream) {
|
||||
keepStreamActive = false;
|
||||
killFfmpeg();
|
||||
player.pause();
|
||||
}
|
||||
|
||||
const previous = getSongText();
|
||||
currentSong = songQueue.shift();
|
||||
if (!currentSong) {
|
||||
@ -179,8 +240,16 @@ player.on('stateChange', (oldState, newState) => {
|
||||
});
|
||||
|
||||
player.on(AudioPlayerStatus.Idle, () => {
|
||||
log('Player idle, playing next song');
|
||||
playNext();
|
||||
if (!keepStreamActive) {
|
||||
killFfmpeg();
|
||||
log('Player idle, playing next song');
|
||||
playNext();
|
||||
return;
|
||||
}
|
||||
|
||||
log('Player idle, keeping stream active, triggering new stream');
|
||||
const { message, words, options } = currentSong.botMeta;
|
||||
stream(message, words, options);
|
||||
});
|
||||
|
||||
player.on('error', (error) => {
|
||||
@ -200,13 +269,13 @@ async function stream(message: Message, words: string[], options: Object | undef
|
||||
const channel = message.channel as TextChannel;
|
||||
if (options['_'].length === 0) {
|
||||
channel.send('No URL provided');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
let url = options['_'][0] ?? null;
|
||||
if (!url) {
|
||||
channel.send('No URL provided');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
@ -215,31 +284,70 @@ async function stream(message: Message, words: string[], options: Object | undef
|
||||
}
|
||||
catch (err) {
|
||||
channel.send(`Invalid URL provided: ${inlineCode(url)}`);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
url = parsed.href;
|
||||
const songMeta = {
|
||||
title: url,
|
||||
title: parsed.hostname,
|
||||
uploader: parsed.hostname,
|
||||
duration: 'N/A',
|
||||
duration: 'Stream',
|
||||
url: url,
|
||||
} as QueueEntry['songMeta'];
|
||||
|
||||
const audioResource = createAudioResource(url, {
|
||||
ffmpegProcess = spawn('ffmpeg', [
|
||||
'-reconnect', '1',
|
||||
'-reconnect_streamed', '1',
|
||||
'-reconnect_delay_max', '5',
|
||||
'-i', url,
|
||||
'-f', 's16le',
|
||||
'-ar', '48000',
|
||||
'-ac', '2',
|
||||
'-loglevel', 'error',
|
||||
'pipe:1'
|
||||
]);
|
||||
|
||||
ffmpegProcess.stderr.on('data', (data) => {
|
||||
logError('FFmpeg error: ' + data.toString());
|
||||
});
|
||||
|
||||
ffmpegProcess.on('close', (code) => {
|
||||
console.log(`FFmpeg process closed with code ${code}`);
|
||||
if (code === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (keepStreamActive) {
|
||||
setTimeout(() => stream(message, words), 5000);
|
||||
}
|
||||
});
|
||||
|
||||
const audioResource = createAudioResource(ffmpegProcess.stdout, {
|
||||
inlineVolume: true,
|
||||
inputType: StreamType.Raw,
|
||||
});
|
||||
|
||||
audioResource.volume.setVolume(volume);
|
||||
|
||||
songQueue.push({
|
||||
url: url,
|
||||
path: url,
|
||||
message: message,
|
||||
audioResource: audioResource,
|
||||
videoInfo: null,
|
||||
songMeta: songMeta,
|
||||
});
|
||||
// Don't add the stream to the queue if it's an automatic trigger, as it's already in the queue
|
||||
if (!keepStreamActive) {
|
||||
songQueue.push({
|
||||
url: url,
|
||||
path: url,
|
||||
message: message,
|
||||
audioResource: audioResource,
|
||||
isStream: true,
|
||||
videoInfo: null,
|
||||
songMeta: songMeta,
|
||||
botMeta: {
|
||||
message,
|
||||
words,
|
||||
options,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
keepStreamActive = true;
|
||||
|
||||
if (!currentSong) {
|
||||
log('Nothing currently playing, triggering playNext()');
|
||||
@ -385,9 +493,15 @@ async function queueUrl(message: Message, words: string[], options: Object | und
|
||||
url: url,
|
||||
path: `${path}.mp3`,
|
||||
message: message,
|
||||
isStream: false,
|
||||
audioResource: audioResource,
|
||||
videoInfo,
|
||||
songMeta: songMeta,
|
||||
botMeta: {
|
||||
message,
|
||||
words,
|
||||
options,
|
||||
}
|
||||
});
|
||||
|
||||
if (!currentSong) {
|
||||
@ -400,11 +514,56 @@ async function queueUrl(message: Message, words: string[], options: Object | und
|
||||
await reply.edit(`Added song to queue: ${videoText}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect to the voice channel
|
||||
*
|
||||
* @param {Message} message
|
||||
* @param {string[]} words
|
||||
*/
|
||||
async function triggerReconnect(message: Message, words: string[])
|
||||
{
|
||||
const channel = message.channel as TextChannel;
|
||||
await channel.send('Reconnecting to voice channel');
|
||||
await newVoiceConnection(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a reaction to a message
|
||||
* Silently fails if it can't add the reaction.
|
||||
*
|
||||
* @param {Message} message
|
||||
* @param {string} reaction
|
||||
* @returns Promise<MessageReaction | null>
|
||||
*/
|
||||
async function addReaction(message: Message, reaction: string) : Promise<MessageReaction | null>
|
||||
{
|
||||
try {
|
||||
return await message.react(reaction);
|
||||
}
|
||||
catch (err) { /* Reactions honestly aren't that important, even if they don't get added, so whatever */ }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to remove a reaction.
|
||||
* It silently fails if it can't remove the reaction, as we don't really care.
|
||||
*
|
||||
* @param {MessageReaction} reaction
|
||||
*/
|
||||
async function removeReaction(reaction: MessageReaction)
|
||||
{
|
||||
try {
|
||||
await reaction.remove();
|
||||
}
|
||||
catch (err) { /* Reactions honestly aren't that important, even if they don't get removed, so whatever */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a song on YouTube and add it to the queue
|
||||
* Will attempt to use the 'Topic' version of the song if it exists
|
||||
*/
|
||||
async function search(message: Message, words: string[])
|
||||
async function search(message: Message, words: string[]) : Promise<boolean>
|
||||
{
|
||||
let input = words[1];
|
||||
if (!input) {
|
||||
@ -430,21 +589,19 @@ async function search(message: Message, words: string[])
|
||||
}
|
||||
|
||||
// Add a "searching" reaction
|
||||
let searching: MessageReaction;
|
||||
try {
|
||||
searching = await message.react('🔍');
|
||||
}
|
||||
catch (err) {
|
||||
// Reactions aren't really important, so we don't care if this fails
|
||||
}
|
||||
|
||||
const searching = await addReaction(message, '🔍');
|
||||
const search = options['_'].join(' ');
|
||||
const results = await yt.searchVideos(search, 10);
|
||||
const escapedSearch = search.includes('\n') ? codeBlock(search) : inlineCode(search);
|
||||
const channel = message.channel as TextChannel;
|
||||
if (results.length === 0) {
|
||||
channel.send(`No results for: ${escapedSearch}`);
|
||||
return;
|
||||
|
||||
if (searching) {
|
||||
removeReaction(searching);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
let result = results[0];
|
||||
@ -457,53 +614,48 @@ async function search(message: Message, words: string[])
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the "searching" reaction
|
||||
if (searching) {
|
||||
try {
|
||||
await searching.remove();
|
||||
}
|
||||
catch (err) {
|
||||
// Perhaps the reaction was removed already? Regardless it's not important
|
||||
}
|
||||
removeReaction(searching);
|
||||
}
|
||||
|
||||
const url = `https://www.youtube.com/watch?v=${result.id.videoId}`;
|
||||
if (!url) {
|
||||
channel.send(`No results for: ${escapedSearch}`);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (url.includes('Invalid')) {
|
||||
channel.send(`Invalid search: ${escapedSearch}`);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add the URL to the queue
|
||||
words[1] = url;
|
||||
queueUrl(message, words, options);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the volume
|
||||
*/
|
||||
async function setVolume(message: Message, words: string[])
|
||||
async function setVolume(message: Message, words: string[]) : Promise<boolean>
|
||||
{
|
||||
const channel = message.channel as TextChannel;
|
||||
if (!words[1]) {
|
||||
const currentVolume = (volume * 100).toString();
|
||||
await channel.send(`Current volume is ${currentVolume}%`);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const newVolume = parseFloat(words[1]);
|
||||
if (isNaN(newVolume)) {
|
||||
await channel.send(`Invalid volume [0-100]: ${inlineCode(words[1])}`);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (newVolume < 0 || newVolume > 100) {
|
||||
await channel.send(`Invalid volume [0-100]: ${inlineCode(words[1])}`);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
volume = newVolume / 100;
|
||||
@ -589,6 +741,7 @@ const handlers = {
|
||||
'pause': pausePlayer,
|
||||
'play': resumePlayer,
|
||||
'queueUrl': queueUrl,
|
||||
'reconnect': triggerReconnect,
|
||||
'search': search,
|
||||
'stop': stopPlayer,
|
||||
'stream': stream,
|
||||
@ -629,47 +782,32 @@ client.on('messageCreate', async (message: Message) => {
|
||||
const handler = handlers[method];
|
||||
if (!handler) {
|
||||
logError(`No handler for method ${method}`);
|
||||
channel.send(`Command not implemented: ${inlineCode(command)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
log(`Command used: ${command} [handler: ${method}] by ${message.author.tag}`);
|
||||
let result: any;
|
||||
try {
|
||||
await handler(message, words);
|
||||
result = await handler(message, words);
|
||||
}
|
||||
catch (err) {
|
||||
logError(err);
|
||||
await message.react('❌');
|
||||
await addReaction(message, '❌');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await message.react('✅');
|
||||
}
|
||||
catch (err) {
|
||||
// Perhaps the reaction was removed already? Regardless it's not important
|
||||
if (result === false) {
|
||||
await addReaction(message, '❌');
|
||||
return;
|
||||
}
|
||||
|
||||
await addReaction(message, '✅');
|
||||
});
|
||||
|
||||
client.on('ready', async () => {
|
||||
log(`Connected to Discord as ${client?.user?.tag}`);
|
||||
|
||||
const channel = await client.channels.fetch(config.voiceChannel) as VoiceChannel;
|
||||
if (!channel) {
|
||||
logError(`Failed to find channel ${config.voiceChannel}`);
|
||||
return;
|
||||
}
|
||||
|
||||
voiceConnection = joinVoiceChannel({
|
||||
channelId: channel.id,
|
||||
guildId: channel.guild.id,
|
||||
adapterCreator: channel.guild.voiceAdapterCreator,
|
||||
});
|
||||
|
||||
if (!voiceConnection) {
|
||||
logError(`Failed to join channel ${config.voiceChannel}`);
|
||||
return;
|
||||
}
|
||||
await newVoiceConnection();
|
||||
|
||||
/**
|
||||
* Thanks to: https://github.com/discordjs/discord.js/issues/9185#issuecomment-1452514375
|
||||
@ -699,15 +837,16 @@ client.on('warn', (warning) => {
|
||||
logError(warning);
|
||||
});
|
||||
|
||||
function shutdown()
|
||||
async function shutdown()
|
||||
{
|
||||
log('Shutting down');
|
||||
killFfmpeg();
|
||||
|
||||
if (voiceConnection) {
|
||||
voiceConnection.destroy();
|
||||
}
|
||||
|
||||
client.destroy();
|
||||
await client.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
@ -6,6 +6,7 @@ export default interface QueueEntry {
|
||||
path: string,
|
||||
message: Message,
|
||||
audioResource: AudioResource,
|
||||
isStream: boolean,
|
||||
// --write-info-json dump from yt-dlp
|
||||
videoInfo: Object,
|
||||
songMeta: {
|
||||
@ -14,4 +15,9 @@ export default interface QueueEntry {
|
||||
duration: string | null | undefined,
|
||||
url: string | null | undefined,
|
||||
},
|
||||
botMeta: {
|
||||
message: Message,
|
||||
words: string[],
|
||||
options: object,
|
||||
},
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user