diff --git a/CHANGELOG.md b/CHANGELOG.md index e46c188..8af6490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.2.1] - 2023-03-04 +### Fixed +- Fixed all lint errors +- Create the guild settings when not found instead of returning an error +- Add temporary workaround to avoid VoiceConnection being stuck in signalling state + +## [2.2.0] - 2023-02-26 +### Added +- Added a '/replay' to restart the current song. Alias for '/seek time: 0' + ## [2.1.9] - 2023-02-14 ### Fixed - Queueing a YouTube playlist sometimes resulted in an infinite loop @@ -215,7 +225,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release -[unreleased]: https://github.com/codetheweb/muse/compare/v2.1.9...HEAD +[unreleased]: https://github.com/codetheweb/muse/compare/v2.2.1...HEAD +[2.2.1]: https://github.com/codetheweb/muse/compare/v2.2.0...v2.2.1 +[2.2.0]: https://github.com/codetheweb/muse/compare/v2.1.9...v2.2.0 [2.1.9]: https://github.com/codetheweb/muse/compare/v2.1.8...v2.1.9 [2.1.8]: https://github.com/codetheweb/muse/compare/v2.1.7...v2.1.8 [2.1.7]: https://github.com/codetheweb/muse/compare/v2.1.6...v2.1.7 diff --git a/package.json b/package.json index 530c782..642217e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "muse", - "version": "2.1.9", + "version": "2.2.1", "description": "🎧 a self-hosted Discord music bot that doesn't suck ", "repository": "git@github.com:codetheweb/muse.git", "author": "Max Isom ", diff --git a/src/bot.ts b/src/bot.ts index f028ce6..406eecd 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -23,9 +23,7 @@ export default class { private readonly commandsByName!: Collection; private readonly commandsByButtonId!: Collection; - constructor( - @inject(TYPES.Client) client: Client, - @inject(TYPES.Config) config: Config) { + constructor(@inject(TYPES.Client) client: Client, @inject(TYPES.Config) config: Config) { this.client = client; this.config = config; this.shouldRegisterCommandsOnBot = config.REGISTER_COMMANDS_ON_BOT; diff --git a/src/commands/config.ts b/src/commands/config.ts index 9ed78d7..158f4f4 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -3,6 +3,7 @@ import {ChatInputCommandInteraction, EmbedBuilder, PermissionFlagsBits} from 'di import {injectable} from 'inversify'; import {prisma} from '../utils/db.js'; import Command from './index.js'; +import {getGuildSettings} from '../utils/get-guild-settings'; @injectable() export default class implements Command { @@ -96,11 +97,7 @@ export default class implements Command { case 'get': { const embed = new EmbedBuilder().setTitle('Config'); - const config = await prisma.setting.findUnique({where: {guildId: interaction.guild!.id}}); - - if (!config) { - throw new Error('no config found'); - } + const config = await getGuildSettings(interaction.guild!.id); const settingsToShow = { 'Playlist Limit': config.playlistLimit, diff --git a/src/commands/replay.ts b/src/commands/replay.ts new file mode 100644 index 0000000..43b1057 --- /dev/null +++ b/src/commands/replay.ts @@ -0,0 +1,42 @@ +import {ChatInputCommandInteraction} from 'discord.js'; +import {TYPES} from '../types.js'; +import {inject, injectable} from 'inversify'; +import PlayerManager from '../managers/player.js'; +import Command from '.'; +import {SlashCommandBuilder} from '@discordjs/builders'; + +@injectable() +export default class implements Command { + public readonly slashCommand = new SlashCommandBuilder() + .setName('replay') + .setDescription('replay the current song'); + + public requiresVC = true; + + private readonly playerManager: PlayerManager; + + constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager) { + this.playerManager = playerManager; + } + + public async execute(interaction: ChatInputCommandInteraction): Promise { + const player = this.playerManager.get(interaction.guild!.id); + + const currentSong = player.getCurrent(); + + if (!currentSong) { + throw new Error('nothing is playing'); + } + + if (currentSong.isLive) { + throw new Error('can\'t replay a livestream'); + } + + await Promise.all([ + player.seek(0), + interaction.deferReply(), + ]); + + await interaction.editReply('👍 replayed the current song'); + } +} diff --git a/src/events/guild-create.ts b/src/events/guild-create.ts index af195ce..d566c13 100644 --- a/src/events/guild-create.ts +++ b/src/events/guild-create.ts @@ -8,20 +8,20 @@ import {REST} from '@discordjs/rest'; import {Setting} from '@prisma/client'; import registerCommandsOnGuild from '../utils/register-commands-on-guild.js'; -export async function createGuildSettings(guild: Guild): Promise { +export async function createGuildSettings(guildId: string): Promise { return prisma.setting.upsert({ where: { - guildId: guild.id, + guildId, }, create: { - guildId: guild.id, + guildId, }, update: {}, }); } export default async (guild: Guild): Promise => { - await createGuildSettings(guild); + await createGuildSettings(guild.id); const config = container.get(TYPES.Config); diff --git a/src/events/voice-state-update.ts b/src/events/voice-state-update.ts index 60a40b1..e941a70 100644 --- a/src/events/voice-state-update.ts +++ b/src/events/voice-state-update.ts @@ -3,7 +3,7 @@ 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'; +import {getGuildSettings} from '../utils/get-guild-settings'; export default async (oldState: VoiceState, _: VoiceState): Promise => { const playerManager = container.get(TYPES.Managers.Player); @@ -12,11 +12,7 @@ export default async (oldState: VoiceState, _: VoiceState): Promise => { if (player.voiceConnection) { const voiceChannel: VoiceChannel = oldState.guild.channels.cache.get(player.voiceConnection.joinConfig.channelId!) as VoiceChannel; - const settings = await prisma.setting.findUnique({where: {guildId: player.guildId}}); - - if (!settings) { - throw new Error('Could not find settings for guild'); - } + const settings = await getGuildSettings(player.guildId); const {leaveIfNoListeners} = settings; if (!voiceChannel || (getSizeWithoutBots(voiceChannel) === 0 && leaveIfNoListeners)) { diff --git a/src/inversify.config.ts b/src/inversify.config.ts index 002d72c..1a3b85c 100644 --- a/src/inversify.config.ts +++ b/src/inversify.config.ts @@ -14,30 +14,31 @@ import GetSongs from './services/get-songs.js'; import YoutubeAPI from './services/youtube-api.js'; import SpotifyAPI from './services/spotify-api.js'; -// Comands +// Commands import Command from './commands'; import Clear from './commands/clear.js'; import Config from './commands/config.js'; import Disconnect from './commands/disconnect.js'; import Favorites from './commands/favorites.js'; import ForwardSeek from './commands/fseek.js'; +import Loop from './commands/loop'; import Move from './commands/move.js'; +import Next from './commands/next.js'; import NowPlaying from './commands/now-playing.js'; import Pause from './commands/pause.js'; import Play from './commands/play.js'; import QueueCommand from './commands/queue.js'; import Remove from './commands/remove.js'; +import Replay from './commands/replay.js'; import Resume from './commands/resume.js'; import Seek from './commands/seek.js'; import Shuffle from './commands/shuffle.js'; import Skip from './commands/skip.js'; -import Next from './commands/next.js'; import Stop from './commands/stop.js'; import Unskip from './commands/unskip.js'; import ThirdParty from './services/third-party.js'; import FileCacheProvider from './services/file-cache.js'; import KeyValueCacheProvider from './services/key-value-cache.js'; -import Loop from './commands/loop'; const container = new Container(); @@ -67,20 +68,21 @@ container.bind(TYPES.Services.SpotifyAPI).to(SpotifyAPI).inSingleton Disconnect, Favorites, ForwardSeek, + Loop, Move, + Next, NowPlaying, Pause, Play, QueueCommand, Remove, + Replay, Resume, Seek, Shuffle, Skip, - Next, Stop, Unskip, - Loop, ].forEach(command => { container.bind(TYPES.Command).to(command).inSingletonScope(); }); diff --git a/src/services/add-query-to-queue.ts b/src/services/add-query-to-queue.ts index c922f2e..744f3c0 100644 --- a/src/services/add-query-to-queue.ts +++ b/src/services/add-query-to-queue.ts @@ -1,14 +1,15 @@ /* eslint-disable complexity */ import {ChatInputCommandInteraction, GuildMember} from 'discord.js'; +import {URL} from 'node:url'; import {inject, injectable} from 'inversify'; import shuffle from 'array-shuffle'; import {TYPES} from '../types.js'; import GetSongs from '../services/get-songs.js'; import {SongMetadata, STATUS} 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'; +import {getGuildSettings} from '../utils/get-guild-settings'; @injectable() export default class AddQueryToQueue { @@ -34,11 +35,7 @@ export default class AddQueryToQueue { 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 settings = await getGuildSettings(guildId); const {playlistLimit} = settings; diff --git a/src/services/get-songs.ts b/src/services/get-songs.ts index fbb4937..1c68e7e 100644 --- a/src/services/get-songs.ts +++ b/src/services/get-songs.ts @@ -1,6 +1,6 @@ import {inject, injectable} from 'inversify'; import * as spotifyURI from 'spotify-uri'; -import {SongMetadata, QueuedPlaylist, MediaSource} from '../services/player.js'; +import {SongMetadata, QueuedPlaylist, MediaSource} from './player'; import {TYPES} from '../types.js'; import ffmpeg from 'fluent-ffmpeg'; import YoutubeAPI from './youtube-api.js'; @@ -11,9 +11,7 @@ export default class { private readonly youtubeAPI: YoutubeAPI; private readonly spotifyAPI: SpotifyAPI; - constructor( - @inject(TYPES.Services.YoutubeAPI) youtubeAPI: YoutubeAPI, - @inject(TYPES.Services.SpotifyAPI) spotifyAPI: SpotifyAPI) { + constructor(@inject(TYPES.Services.YoutubeAPI) youtubeAPI: YoutubeAPI, @inject(TYPES.Services.SpotifyAPI) spotifyAPI: SpotifyAPI) { this.youtubeAPI = youtubeAPI; this.spotifyAPI = spotifyAPI; } diff --git a/src/services/player.ts b/src/services/player.ts index 78f72b2..9c1a122 100644 --- a/src/services/player.ts +++ b/src/services/player.ts @@ -18,7 +18,7 @@ import { } from '@discordjs/voice'; import FileCacheProvider from './file-cache.js'; import debug from '../utils/debug.js'; -import {prisma} from '../utils/db.js'; +import {getGuildSettings} from '../utils/get-guild-settings'; export enum MediaSource { Youtube, @@ -84,6 +84,22 @@ export default class { guildId: channel.guild.id, adapterCreator: channel.guild.voiceAdapterCreator as DiscordGatewayAdapterCreator, }); + + // Workaround to disable keepAlive + this.voiceConnection.on('stateChange', (oldState, newState) => { + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */ + const oldNetworking = Reflect.get(oldState, 'networking'); + const newNetworking = Reflect.get(newState, 'networking'); + + const networkStateChangeHandler = (_: any, newNetworkState: any) => { + const newUdp = Reflect.get(newNetworkState, 'udp'); + clearInterval(newUdp?.keepAliveInterval); + }; + + oldNetworking?.off('stateChange', networkStateChangeHandler); + newNetworking?.on('stateChange', networkStateChangeHandler); + /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */ + }); } disconnect(): void { @@ -256,11 +272,7 @@ export default class { 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 settings = await getGuildSettings(this.guildId); const {secondsToWaitAfterQueueEmpties} = settings; if (secondsToWaitAfterQueueEmpties !== 0) { diff --git a/src/services/youtube-api.ts b/src/services/youtube-api.ts index 01a29dc..d2528d4 100644 --- a/src/services/youtube-api.ts +++ b/src/services/youtube-api.ts @@ -29,10 +29,7 @@ export default class { private readonly ytsrQueue: PQueue; - constructor( - @inject(TYPES.ThirdParty) thirdParty: ThirdParty, - @inject(TYPES.Config) config: Config, - @inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider) { + constructor(@inject(TYPES.ThirdParty) thirdParty: ThirdParty, @inject(TYPES.Config) config: Config, @inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider) { this.youtube = thirdParty.youtube; this.youtubeKey = config.YOUTUBE_API_KEY; this.cache = cache; diff --git a/src/utils/get-guild-settings.ts b/src/utils/get-guild-settings.ts new file mode 100644 index 0000000..f001aab --- /dev/null +++ b/src/utils/get-guild-settings.ts @@ -0,0 +1,12 @@ +import {Setting} from '@prisma/client'; +import {prisma} from './db'; +import {createGuildSettings} from '../events/guild-create'; + +export async function getGuildSettings(guildId: string): Promise { + const config = await prisma.setting.findUnique({where: {guildId}}); + if (!config) { + return createGuildSettings(guildId); + } + + return config; +}