diff --git a/package.json b/package.json index 06d8d2e..00c4143 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "libsodium-wrappers": "^0.7.10", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", - "rxjs": "^7.2.0" + "rxjs": "^7.2.0", + "uuid": "^9.0.0" }, "devDependencies": { "@nestjs/cli": "^9.0.0", diff --git a/src/app.module.ts b/src/app.module.ts index 4c6d7a7..a8015a1 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -11,6 +11,7 @@ import { DiscordClientModule } from './clients/discord/discord.module'; import { JellyfinClientModule } from './clients/jellyfin/jellyfin.module'; import { CommandModule } from './commands/command.module'; import { DiscordConfigService } from './clients/discord/discord.config.service'; +import { PlaybackModule } from './playback/playback.module'; @Module({ imports: [ @@ -30,6 +31,7 @@ import { DiscordConfigService } from './clients/discord/discord.config.service'; CommandModule, DiscordClientModule, JellyfinClientModule, + PlaybackModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/clients/discord/discord.message.service.ts b/src/clients/discord/discord.message.service.ts new file mode 100644 index 0000000..d033a2f --- /dev/null +++ b/src/clients/discord/discord.message.service.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; +import { APIEmbed, EmbedBuilder } from 'discord.js'; +import { DefaultJellyfinColor, ErrorJellyfinColor } from '../../types/colors'; + +@Injectable() +export class DiscordMessageService { + buildErrorMessage({ + title, + description, + }: { + title: string; + description?: string; + }): APIEmbed { + const embedBuilder = new EmbedBuilder() + .setColor(ErrorJellyfinColor) + .setAuthor({ + name: title, + iconURL: + 'https://github.com/manuel-rw/jellyfin-discord-music-bot/blob/nestjs-migration/images/icons/alert-circle.png?raw=true', + }); + + if (description !== undefined) { + embedBuilder.setDescription(description); + } + + return embedBuilder.toJSON(); + } + + buildMessage({ + title, + description, + }: { + title: string; + description?: string; + }): APIEmbed { + const embedBuilder = new EmbedBuilder() + .setColor(DefaultJellyfinColor) + .setAuthor({ + name: title, + iconURL: + 'https://github.com/manuel-rw/jellyfin-discord-music-bot/blob/nestjs-migration/images/icons/circle-check.png?raw=true', + }); + + if (description !== undefined) { + embedBuilder.setDescription(description); + } + + return embedBuilder.toJSON(); + } +} diff --git a/src/clients/discord/discord.module.ts b/src/clients/discord/discord.module.ts index 27d4a92..17808af 100644 --- a/src/clients/discord/discord.module.ts +++ b/src/clients/discord/discord.module.ts @@ -1,13 +1,14 @@ import { Module } from '@nestjs/common'; import { OnModuleDestroy } from '@nestjs/common/interfaces/hooks'; import { DiscordConfigService } from './discord.config.service'; +import { DiscordMessageService } from './discord.message.service'; import { DiscordVoiceService } from './discord.voice.service'; @Module({ imports: [], controllers: [], - providers: [DiscordConfigService, DiscordVoiceService], - exports: [DiscordConfigService], + providers: [DiscordConfigService, DiscordVoiceService, DiscordMessageService], + exports: [DiscordConfigService, DiscordMessageService], }) export class DiscordClientModule implements OnModuleDestroy { constructor(private readonly discordVoiceService: DiscordVoiceService) {} diff --git a/src/commands/command.module.ts b/src/commands/command.module.ts index c014edd..0d50d1b 100644 --- a/src/commands/command.module.ts +++ b/src/commands/command.module.ts @@ -11,6 +11,8 @@ import { PlayCommand } from './play.command'; import { SkipTrackCommand } from './skip.command'; import { StopPlaybackCommand } from './stop.command'; import { SummonCommand } from './summon.command'; +import { DiscordMessageService } from '../clients/discord/discord.message.service'; +import { PlaybackService } from '../playback/playback.service'; @Module({ imports: [DiscordModule.forFeature()], @@ -26,6 +28,8 @@ import { SummonCommand } from './summon.command'; SkipTrackCommand, StopPlaybackCommand, SummonCommand, + DiscordMessageService, + PlaybackService, ], exports: [], }) diff --git a/src/commands/enqueue.command.ts b/src/commands/enqueue.command.ts index 824a548..3c7eea4 100644 --- a/src/commands/enqueue.command.ts +++ b/src/commands/enqueue.command.ts @@ -7,17 +7,37 @@ import { UsePipes, } from '@discord-nestjs/core'; import { InteractionReplyOptions } from 'discord.js'; +import { DiscordMessageService } from '../clients/discord/discord.message.service'; +import { TrackRequestDto } from '../models/track-request.dto'; +import { PlaybackService } from '../playback/playback.service'; @Command({ name: 'enqueue', description: 'Enqueue a track to the current playlist', }) @UsePipes(TransformPipe) -export class EnqueueCommand implements DiscordTransformedCommand { +export class EnqueueCommand + implements DiscordTransformedCommand +{ + constructor( + private readonly discordMessageService: DiscordMessageService, + private readonly playbackService: PlaybackService, + ) {} + handler( - dto: unknown, + dto: TrackRequestDto, executionContext: TransformedCommandExecutionContext, ): InteractionReplyOptions | string { - return 'nice'; + const index = this.playbackService.eneuqueTrack({}); + return { + embeds: [ + this.discordMessageService.buildMessage({ + title: `Track Added to queue`, + description: `Your track \`\`${ + dto.search + }\`\` was added to the queue at position \`\`${index + 1}\`\``, + }), + ], + }; } } diff --git a/src/commands/play.command.ts b/src/commands/play.command.ts index 137cb84..c6a83a1 100644 --- a/src/commands/play.command.ts +++ b/src/commands/play.command.ts @@ -6,7 +6,12 @@ import { TransformedCommandExecutionContext, UsePipes, } from '@discord-nestjs/core'; -import { InteractionReplyOptions, MessagePayload } from 'discord.js'; +import { + EmbedBuilder, + GuildMember, + InteractionReplyOptions, + MessagePayload, +} from 'discord.js'; import { Injectable } from '@nestjs/common'; import { TrackRequestDto } from '../models/track-request.dto'; @@ -14,8 +19,10 @@ import { createAudioPlayer, createAudioResource, getVoiceConnection, + joinVoiceChannel, } from '@discordjs/voice'; import { Logger } from '@nestjs/common/services'; +import { DiscordMessageService } from '../clients/discord/discord.message.service'; @Command({ name: 'play', @@ -26,6 +33,8 @@ import { Logger } from '@nestjs/common/services'; export class PlayCommand implements DiscordTransformedCommand { private readonly logger = new Logger(PlayCommand.name); + constructor(private readonly discordMessageService: DiscordMessageService) {} + handler( @Payload() dto: TrackRequestDto, executionContext: TransformedCommandExecutionContext, @@ -35,27 +44,53 @@ export class PlayCommand implements DiscordTransformedCommand { | MessagePayload | InteractionReplyOptions | Promise { - const player = createAudioPlayer(); + const guildMember = executionContext.interaction.member as GuildMember; - this.logger.debug('bruh'); + if (guildMember.voice.channel === null) { + return { + embeds: [ + this.discordMessageService.buildErrorMessage({ + title: 'Unable to join your channel', + description: + 'You are in a channel, I am either unabelt to connect to or you aren&apost in a channel yet', + }), + ], + }; + } - player.on('error', (error) => { - this.logger.error(error); + const channel = guildMember.voice.channel; + + joinVoiceChannel({ + channelId: channel.id, + adapterCreator: channel.guild.voiceAdapterCreator, + guildId: channel.guildId, }); - player.on('debug', (error) => { - this.logger.debug(error); - }); - - const resource = createAudioResource(dto.search); - const connection = getVoiceConnection(executionContext.interaction.guildId); + if (!connection) { + return { + embeds: [ + this.discordMessageService.buildErrorMessage({ + title: 'Unable to establish audio connection', + description: + 'I was unable to establish an audio connection to your voice channel', + }), + ], + }; + } + + const player = createAudioPlayer(); + + const resource = createAudioResource(dto.search); + connection.subscribe(player); player.play(resource); player.unpause(); - return 'Playing Audio...'; + return { + embeds: [new EmbedBuilder().setTitle(`Playing ${dto.search}`).toJSON()], + }; } } diff --git a/src/commands/summon.command.ts b/src/commands/summon.command.ts index 6f39006..71b0f4a 100644 --- a/src/commands/summon.command.ts +++ b/src/commands/summon.command.ts @@ -7,9 +7,9 @@ import { CommandInteraction, EmbedBuilder, GuildMember, - InteractionReplyOptions + InteractionReplyOptions, } from 'discord.js'; -import { DefaultJellyfinColor } from '../types/colors'; +import { DefaultJellyfinColor, ErrorJellyfinColor } from '../types/colors'; @Command({ name: 'summon', @@ -26,7 +26,7 @@ export class SummonCommand implements DiscordCommand { return { embeds: [ new EmbedBuilder() - .setColor(DefaultJellyfinColor) + .setColor(ErrorJellyfinColor) .setAuthor({ name: 'Unable to join your channel', iconURL: diff --git a/src/playback/playback.module.ts b/src/playback/playback.module.ts new file mode 100644 index 0000000..2a5b408 --- /dev/null +++ b/src/playback/playback.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PlaybackService } from './playback.service'; + +@Module({ + imports: [], + controllers: [], + providers: [PlaybackService], + exports: [PlaybackService], +}) +export class PlaybackModule {} diff --git a/src/playback/playback.service.ts b/src/playback/playback.service.ts new file mode 100644 index 0000000..301bf64 --- /dev/null +++ b/src/playback/playback.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { Playlist } from '../types/playlist'; +import { Track } from '../types/track'; + +import { v4 as uuidv4 } from 'uuid'; + +@Injectable() +export class PlaybackService { + private readonly playlist: Playlist = { + tracks: [], + activeTrack: null, + }; + + getActiveTrack() { + return this.getTrackById(this.playlist.activeTrack); + } + + setActiveTrack(trackId: string) { + const track = this.getTrackById(trackId); + + if (!track) { + throw Error('track is not in playlist'); + } + + this.playlist.activeTrack = track.id; + } + + eneuqueTrack(track: Track) { + const uuid = uuidv4(); + this.playlist.tracks.push({ + id: uuid, + track: track, + }); + return this.playlist.tracks.findIndex((x) => x.id === uuid); + } + + set(tracks: Track[]) { + this.playlist.tracks = tracks.map((t) => ({ + id: uuidv4(), + track: t, + })); + } + + clear() { + this.playlist.tracks = []; + } + + private getTrackById(id: string) { + return this.playlist.tracks.find((x) => x.id === id); + } +} diff --git a/src/types/playlist.ts b/src/types/playlist.ts new file mode 100644 index 0000000..dabbb40 --- /dev/null +++ b/src/types/playlist.ts @@ -0,0 +1,9 @@ +import { Track } from './track'; + +export interface Playlist { + tracks: { + id: string; + track: Track; + }[]; + activeTrack: string | null; +} diff --git a/src/types/track.ts b/src/types/track.ts new file mode 100644 index 0000000..c7f9f8b --- /dev/null +++ b/src/types/track.ts @@ -0,0 +1 @@ +export interface Track {} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 46c427b..06e28ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4487,6 +4487,7 @@ __metadata: ts-node: ^10.0.0 tsconfig-paths: 4.1.0 typescript: ^4.7.4 + uuid: ^9.0.0 languageName: unknown linkType: soft @@ -7246,7 +7247,7 @@ __metadata: languageName: node linkType: hard -"uuid@npm:9.0.0": +"uuid@npm:9.0.0, uuid@npm:^9.0.0": version: 9.0.0 resolution: "uuid@npm:9.0.0" bin: