Fix stream command, add reconnect command

This commit is contained in:
Alex Thomassen 2024-10-05 18:10:24 +00:00
parent 0c01c800f6
commit 4fc0d2f2c8
Signed by: Alex
GPG Key ID: 10BD786B5F6FF5DE
3 changed files with 213 additions and 66 deletions

View File

@ -23,6 +23,8 @@ module.exports = {
'!volume': 'volume',
'!vol': 'volume',
'!reconnect': 'reconnect',
},
/**

271
index.ts
View File

@ -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);
}

View File

@ -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,
},
}