From 4dbb55a72119a61033d2df6e8cf46f5eaa7bba15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20V=C3=A4=C3=A4r=C3=A4kangas?= Date: Sat, 12 Feb 2022 04:05:02 +0200 Subject: [PATCH] Configurable voice channel leave behavior (#514) Co-authored-by: Max Isom --- CHANGELOG.md | 2 + .../migration.sql | 16 ++++++ nodemon.json | 2 +- schema.prisma | 12 +++-- src/commands/config.ts | 53 +++++++++++++++++++ src/events/voice-state-update.ts | 12 ++++- src/services/add-query-to-queue.ts | 5 +- src/services/player.ts | 32 ++++++++++- 8 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 migrations/20220212014052_add_seconds_to_wait_after_queue_empties_and_leave_if_no_listeners/migration.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 76fba8d..a08bd69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed +- Muse now stays in a voice channel after the queue finishes for 30 seconds by default. This behavior can be changed with `/config set-wait-after-queue-empties`. ## [1.0.0] - 2022-02-05 ### Changed diff --git a/migrations/20220212014052_add_seconds_to_wait_after_queue_empties_and_leave_if_no_listeners/migration.sql b/migrations/20220212014052_add_seconds_to_wait_after_queue_empties_and_leave_if_no_listeners/migration.sql new file mode 100644 index 0000000..8079195 --- /dev/null +++ b/migrations/20220212014052_add_seconds_to_wait_after_queue_empties_and_leave_if_no_listeners/migration.sql @@ -0,0 +1,16 @@ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Setting" ( + "guildId" TEXT NOT NULL PRIMARY KEY, + "playlistLimit" INTEGER NOT NULL DEFAULT 50, + "secondsToWaitAfterQueueEmpties" INTEGER NOT NULL DEFAULT 30, + "leaveIfNoListeners" BOOLEAN NOT NULL DEFAULT true, + "roleId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_Setting" ("createdAt", "guildId", "playlistLimit", "roleId", "updatedAt") SELECT "createdAt", "guildId", "playlistLimit", "roleId", "updatedAt" FROM "Setting"; +DROP TABLE "Setting"; +ALTER TABLE "new_Setting" RENAME TO "Setting"; +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/nodemon.json b/nodemon.json index da6a545..44ab8fb 100644 --- a/nodemon.json +++ b/nodemon.json @@ -1,6 +1,6 @@ { "ignore": ["**/*.test.ts", "**/*.spec.ts", ".git", "node_modules"], "watch": ["dist"], - "exec": "npm run env:set-database-url -- node --experimental-json-modules dist/src/scripts/start.js", + "exec": "npm run env:set-database-url -- node --experimental-json-modules dist/scripts/start.js", "ext": "js" } diff --git a/schema.prisma b/schema.prisma index 66ea3b7..5b4e898 100644 --- a/schema.prisma +++ b/schema.prisma @@ -24,11 +24,13 @@ model KeyValueCache { } model Setting { - guildId String @id - playlistLimit Int @default(50) - roleId String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + guildId String @id + playlistLimit Int @default(50) + secondsToWaitAfterQueueEmpties Int @default(30) + leaveIfNoListeners Boolean @default(true) + roleId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model FavoriteQuery { diff --git a/src/commands/config.ts b/src/commands/config.ts index 65aa36b..b0a5215 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -24,6 +24,21 @@ export default class implements Command { .setName('role') .setDescription('allowed role') .setRequired(true))) + .addSubcommand(subcommand => subcommand + .setName('set-wait-after-queue-empties') + .setDescription('set the time to wait before leaving the voice channel when queue empties') + .addIntegerOption(option => option + .setName('delay') + .setDescription('delay in seconds (set to 0 to never leave)') + .setRequired(true) + .setMinValue(0))) + .addSubcommand(subcommand => subcommand + .setName('set-leave-if-no-listeners') + .setDescription('set whether to leave when all other participants leave') + .addBooleanOption(option => option + .setName('value') + .setDescription('whether to leave when everyone else leaves') + .setRequired(true))) .addSubcommand(subcommand => subcommand .setName('get') .setDescription('show all settings')); @@ -70,6 +85,40 @@ export default class implements Command { break; } + case 'set-wait-after-queue-empty': { + const delay = interaction.options.getInteger('delay')!; + + await prisma.setting.update({ + where: { + guildId: interaction.guild!.id, + }, + data: { + secondsToWaitAfterQueueEmpties: delay, + }, + }); + + await interaction.reply('👍 wait delay updated'); + + break; + } + + case 'set-leave-if-no-listeners': { + const value = interaction.options.getBoolean('value')!; + + await prisma.setting.update({ + where: { + guildId: interaction.guild!.id, + }, + data: { + leaveIfNoListeners: value, + }, + }); + + await interaction.reply('👍 leave setting updated'); + + break; + } + case 'get': { const embed = new MessageEmbed().setTitle('Config'); @@ -82,6 +131,10 @@ export default class implements Command { const settingsToShow = { 'Playlist Limit': config.playlistLimit, Role: config.roleId ? `<@&${config.roleId}>` : 'not set', + 'Wait before leaving after queue empty': config.secondsToWaitAfterQueueEmpties === 0 + ? 'never leave' + : `${config.secondsToWaitAfterQueueEmpties}s`, + 'Leave if there are no listeners': config.leaveIfNoListeners ? 'yes' : 'no', }; let description = ''; diff --git a/src/events/voice-state-update.ts b/src/events/voice-state-update.ts index 69b4504..60a40b1 100644 --- a/src/events/voice-state-update.ts +++ b/src/events/voice-state-update.ts @@ -3,15 +3,23 @@ import container from '../inversify.config.js'; import {TYPES} from '../types.js'; import PlayerManager from '../managers/player.js'; import {getSizeWithoutBots} from '../utils/channels.js'; +import {prisma} from '../utils/db.js'; -export default (oldState: VoiceState, _: VoiceState): void => { +export default async (oldState: VoiceState, _: VoiceState): Promise => { const playerManager = container.get(TYPES.Managers.Player); const player = playerManager.get(oldState.guild.id); if (player.voiceConnection) { const voiceChannel: VoiceChannel = oldState.guild.channels.cache.get(player.voiceConnection.joinConfig.channelId!) as VoiceChannel; - if (!voiceChannel || getSizeWithoutBots(voiceChannel) === 0) { + const settings = await prisma.setting.findUnique({where: {guildId: player.guildId}}); + + if (!settings) { + throw new Error('Could not find settings for guild'); + } + + const {leaveIfNoListeners} = settings; + if (!voiceChannel || (getSizeWithoutBots(voiceChannel) === 0 && leaveIfNoListeners)) { player.disconnect(); } } diff --git a/src/services/add-query-to-queue.ts b/src/services/add-query-to-queue.ts index bb512c6..16d2f43 100644 --- a/src/services/add-query-to-queue.ts +++ b/src/services/add-query-to-queue.ts @@ -4,7 +4,7 @@ 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 {QueuedSong, STATUS} from './player.js'; import PlayerManager from '../managers/player.js'; import {prisma} from '../utils/db.js'; import {buildPlayingMessageEmbed} from '../utils/build-embed.js'; @@ -131,6 +131,9 @@ export default class AddQueryToQueue { await interaction.editReply({ embeds: [buildPlayingMessageEmbed(player)], }); + } else if (player.status === STATUS.IDLE) { + // Player is idle, start playback instead + await player.play(); } // Build response message diff --git a/src/services/player.ts b/src/services/player.ts index ce611e8..79ef2bb 100644 --- a/src/services/player.ts +++ b/src/services/player.ts @@ -8,6 +8,7 @@ import shuffle from 'array-shuffle'; import {AudioPlayer, AudioPlayerStatus, createAudioPlayer, createAudioResource, joinVoiceChannel, StreamType, VoiceConnection, VoiceConnectionStatus} from '@discordjs/voice'; import FileCacheProvider from './file-cache.js'; import debug from '../utils/debug.js'; +import {prisma} from '../utils/db.js'; export interface QueuedPlaylist { title: string; @@ -29,6 +30,7 @@ export interface QueuedSong { export enum STATUS { PLAYING, PAUSED, + IDLE, } export interface PlayerEvents { @@ -50,6 +52,7 @@ export default class { private positionInSeconds = 0; private readonly fileCache: FileCacheProvider; + private disconnectTimer: NodeJS.Timeout | null = null; constructor(fileCache: FileCacheProvider, guildId: string) { this.fileCache = fileCache; @@ -131,6 +134,12 @@ export default class { throw new Error('Queue empty.'); } + // Cancel any pending idle disconnection + if (this.disconnectTimer) { + clearInterval(this.disconnectTimer); + this.disconnectTimer = null; + } + // Resume from paused state if (this.status === STATUS.PAUSED && currentSong.url === this.nowPlaying?.url) { if (this.audioPlayer) { @@ -206,12 +215,31 @@ export default class { async forward(skip: number): Promise { this.manualForward(skip); + console.log(this.getCurrent()); + try { if (this.getCurrent() && this.status !== STATUS.PAUSED) { await this.play(); } else { - this.status = STATUS.PAUSED; - this.disconnect(); + this.audioPlayer?.stop(); + this.status = STATUS.IDLE; + + const settings = await prisma.setting.findUnique({where: {guildId: this.guildId}}); + + if (!settings) { + throw new Error('Could not find settings for guild'); + } + + const {secondsToWaitAfterQueueEmpties} = settings; + if (secondsToWaitAfterQueueEmpties !== 0) { + this.disconnectTimer = setTimeout(() => { + // Make sure we are not accidentally playing + // when disconnecting + if (this.status === STATUS.IDLE) { + this.disconnect(); + } + }, secondsToWaitAfterQueueEmpties * 1000); + } } } catch (error: unknown) { this.queuePosition--;