diff --git a/src/clients/discord/discord.message.service.ts b/src/clients/discord/discord.message.service.ts index d033a2f..b8b96cd 100644 --- a/src/clients/discord/discord.message.service.ts +++ b/src/clients/discord/discord.message.service.ts @@ -2,6 +2,9 @@ import { Injectable } from '@nestjs/common'; import { APIEmbed, EmbedBuilder } from 'discord.js'; import { DefaultJellyfinColor, ErrorJellyfinColor } from '../../types/colors'; +import { formatRFC7231 } from 'date-fns'; +import { Constants } from '../../utils/constants'; + @Injectable() export class DiscordMessageService { buildErrorMessage({ @@ -11,40 +14,48 @@ export class DiscordMessageService { 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(); + const date = formatRFC7231(new Date()); + return this.buildMessage({ + title: title, + description: description, + mixin(embedBuilder) { + return embedBuilder + .setFooter({ + text: `${date} - Report an issue: ${Constants.Links.ReportIssue}`, + }) + .setColor(ErrorJellyfinColor); + }, + }); } buildMessage({ title, description, + mixin = (builder) => builder, }: { title: string; description?: string; + mixin?: (embedBuilder: EmbedBuilder) => EmbedBuilder; }): APIEmbed { - const embedBuilder = new EmbedBuilder() + const date = formatRFC7231(new Date()); + + let 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', + }) + .setFooter({ + text: `${date}`, }); - if (description !== undefined) { - embedBuilder.setDescription(description); + if (description !== undefined && description.length > 0) { + embedBuilder = embedBuilder.setDescription(description); } + embedBuilder = mixin(embedBuilder); + return embedBuilder.toJSON(); } } diff --git a/src/clients/discord/discord.module.ts b/src/clients/discord/discord.module.ts index 17808af..e59427c 100644 --- a/src/clients/discord/discord.module.ts +++ b/src/clients/discord/discord.module.ts @@ -8,7 +8,7 @@ import { DiscordVoiceService } from './discord.voice.service'; imports: [], controllers: [], providers: [DiscordConfigService, DiscordVoiceService, DiscordMessageService], - exports: [DiscordConfigService, DiscordMessageService], + exports: [DiscordConfigService, DiscordVoiceService, DiscordMessageService], }) export class DiscordClientModule implements OnModuleDestroy { constructor(private readonly discordVoiceService: DiscordVoiceService) {} diff --git a/src/clients/discord/discord.voice.service.ts b/src/clients/discord/discord.voice.service.ts index 552d37b..79ca9bd 100644 --- a/src/clients/discord/discord.voice.service.ts +++ b/src/clients/discord/discord.voice.service.ts @@ -1,10 +1,81 @@ -import { getVoiceConnections } from '@discordjs/voice'; +import { + AudioPlayer, + AudioResource, + createAudioPlayer, + getVoiceConnection, + getVoiceConnections, + joinVoiceChannel, + VoiceConnection, +} from '@discordjs/voice'; import { Injectable } from '@nestjs/common'; import { Logger } from '@nestjs/common/services'; +import { GuildMember } from 'discord.js'; +import { GenericTryHandler } from '../../models/generic-try-handler'; +import { DiscordMessageService } from './discord.message.service'; @Injectable() export class DiscordVoiceService { private readonly logger = new Logger(DiscordVoiceService.name); + private audioPlayer: AudioPlayer; + private voiceConnection: VoiceConnection; + + constructor(private readonly discordMessageService: DiscordMessageService) {} + + tryJoinChannelAndEstablishVoiceConnection( + member: GuildMember, + ): GenericTryHandler { + if (this.voiceConnection !== undefined) { + return { + success: false, + reply: {}, + }; + } + + if (member.voice.channel === null) { + return { + success: false, + reply: { + embeds: [ + this.discordMessageService.buildErrorMessage({ + title: 'Unable to join your channel', + description: + "I am unable to join your channel, because you don't seem to be in a voice channel. Connect to a channel first to use this command", + }), + ], + }, + }; + } + + const channel = member.voice.channel; + + joinVoiceChannel({ + channelId: channel.id, + adapterCreator: channel.guild.voiceAdapterCreator, + guildId: channel.guildId, + }); + + if (this.voiceConnection == undefined) { + this.voiceConnection = getVoiceConnection(member.guild.id); + } + + return { + success: true, + reply: {}, + }; + } + + playResource(resource: AudioResource) { + this.createAndReturnOrGetAudioPlayer().play(resource); + } + + pause() { + this.createAndReturnOrGetAudioPlayer().pause(); + } + + unpause() { + this.createAndReturnOrGetAudioPlayer().unpause(); + } + disconnectGracefully() { const connections = getVoiceConnections(); this.logger.debug( @@ -17,4 +88,23 @@ export class DiscordVoiceService { connection.destroy(); }); } + + private createAndReturnOrGetAudioPlayer() { + if (this.audioPlayer === undefined) { + this.logger.debug( + `Initialized new instance of Audio Player because it has not been defined yet`, + ); + this.audioPlayer = createAudioPlayer(); + this.audioPlayer.on('debug', (message) => { + this.logger.debug(message); + }); + this.audioPlayer.on('error', (message) => { + this.logger.error(message); + }); + this.voiceConnection.subscribe(this.audioPlayer); + return this.audioPlayer; + } + + return this.audioPlayer; + } } diff --git a/src/clients/jellyfin/jellyfin.module.ts b/src/clients/jellyfin/jellyfin.module.ts index 02a8dcf..40e8818 100644 --- a/src/clients/jellyfin/jellyfin.module.ts +++ b/src/clients/jellyfin/jellyfin.module.ts @@ -1,13 +1,23 @@ import { Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { JellyfinSearchService } from './jellyfin.search.service'; import { JellyfinService } from './jellyfin.service'; +import { JellyfinStreamBuilderService } from './jellyfin.stream.builder.service'; import { JellyinWebsocketService } from './jellyfin.websocket.service'; @Module({ imports: [], controllers: [], - providers: [JellyfinService, JellyinWebsocketService, JellyfinSearchService], - exports: [JellyfinService, JellyfinSearchService], + providers: [ + JellyfinService, + JellyinWebsocketService, + JellyfinSearchService, + JellyfinStreamBuilderService, + ], + exports: [ + JellyfinService, + JellyfinSearchService, + JellyfinStreamBuilderService, + ], }) export class JellyfinClientModule implements OnModuleInit, OnModuleDestroy { constructor( diff --git a/src/clients/jellyfin/jellyfin.stream.builder.service.ts b/src/clients/jellyfin/jellyfin.stream.builder.service.ts new file mode 100644 index 0000000..a2a61ca --- /dev/null +++ b/src/clients/jellyfin/jellyfin.stream.builder.service.ts @@ -0,0 +1,31 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { JellyfinService } from './jellyfin.service'; + +import { getUniversalAudioApi } from '@jellyfin/sdk/lib/utils/api/universal-audio-api'; + +@Injectable() +export class JellyfinStreamBuilderService { + private readonly logger = new Logger(JellyfinStreamBuilderService.name); + + constructor(private readonly jellyfinService: JellyfinService) {} + + async buildStreamUrl(jellyfinItemId: string, bitrate: number) { + const api = this.jellyfinService.getApi(); + + this.logger.debug( + `Attempting to build stream resource for item ${jellyfinItemId} with bitrate ${bitrate}`, + ); + + const accessToken = this.jellyfinService.getApi().accessToken; + + const url = encodeURI( + `${ + api.basePath + }/Audio/${jellyfinItemId}/universal?UserId=${this.jellyfinService.getUserId()}&DeviceId=${ + this.jellyfinService.getJellyfin().clientInfo.name + }&MaxStreamingBitrate=${bitrate}&Container=ogg,opus&AudioCodec=opus&TranscodingContainer=ts&TranscodingProtocol=hls&api_key=${accessToken}`, + ); + + return url; + } +} diff --git a/src/commands/command.module.ts b/src/commands/command.module.ts index 3358d1b..0b04605 100644 --- a/src/commands/command.module.ts +++ b/src/commands/command.module.ts @@ -2,6 +2,7 @@ import { DiscordModule } from '@discord-nestjs/core'; import { Module } from '@nestjs/common'; import { DiscordMessageService } from '../clients/discord/discord.message.service'; +import { DiscordClientModule } from '../clients/discord/discord.module'; import { JellyfinClientModule } from '../clients/jellyfin/jellyfin.module'; import { PlaybackService } from '../playback/playback.service'; import { CurrentTrackCommand } from './current.command'; @@ -17,7 +18,11 @@ import { StopPlaybackCommand } from './stop.command'; import { SummonCommand } from './summon.command'; @Module({ - imports: [DiscordModule.forFeature(), JellyfinClientModule], + imports: [ + DiscordModule.forFeature(), + JellyfinClientModule, + DiscordClientModule, + ], controllers: [], providers: [ HelpCommand, diff --git a/src/commands/pause.command.ts b/src/commands/pause.command.ts index c15707c..833cd3e 100644 --- a/src/commands/pause.command.ts +++ b/src/commands/pause.command.ts @@ -2,24 +2,39 @@ import { TransformPipe } from '@discord-nestjs/common'; import { Command, + CommandExecutionContext, + DiscordCommand, DiscordTransformedCommand, TransformedCommandExecutionContext, UsePipes, } from '@discord-nestjs/core'; -import { InteractionReplyOptions } from 'discord.js'; +import { + ButtonInteraction, + CacheType, + ChatInputCommandInteraction, + ContextMenuCommandInteraction, + Interaction, + InteractionReplyOptions, + MessagePayload, + StringSelectMenuInteraction, +} from 'discord.js'; @Command({ name: 'pause', description: 'Pause or resume the playback of the current track', }) @UsePipes(TransformPipe) -export class PausePlaybackCommand - implements DiscordTransformedCommand -{ +export class PausePlaybackCommand implements DiscordCommand { handler( - dto: unknown, - executionContext: TransformedCommandExecutionContext, - ): InteractionReplyOptions | string { - return 'nice'; + interaction: + | ChatInputCommandInteraction + | ContextMenuCommandInteraction, + executionContext: CommandExecutionContext< + StringSelectMenuInteraction | ButtonInteraction + >, + ): string | InteractionReplyOptions { + return { + content: 'test', + }; } } diff --git a/src/commands/play.command.ts b/src/commands/play.command.ts index c6a83a1..259810d 100644 --- a/src/commands/play.command.ts +++ b/src/commands/play.command.ts @@ -6,23 +6,14 @@ import { TransformedCommandExecutionContext, UsePipes, } from '@discord-nestjs/core'; -import { - EmbedBuilder, - GuildMember, - InteractionReplyOptions, - MessagePayload, -} from 'discord.js'; +import { GuildMember, InteractionReplyOptions } from 'discord.js'; +import { createAudioResource } from '@discordjs/voice'; import { Injectable } from '@nestjs/common'; -import { TrackRequestDto } from '../models/track-request.dto'; -import { - createAudioPlayer, - createAudioResource, - getVoiceConnection, - joinVoiceChannel, -} from '@discordjs/voice'; import { Logger } from '@nestjs/common/services'; import { DiscordMessageService } from '../clients/discord/discord.message.service'; +import { DiscordVoiceService } from '../clients/discord/discord.voice.service'; +import { TrackRequestDto } from '../models/track-request.dto'; @Command({ name: 'play', @@ -33,64 +24,37 @@ import { DiscordMessageService } from '../clients/discord/discord.message.servic export class PlayCommand implements DiscordTransformedCommand { private readonly logger = new Logger(PlayCommand.name); - constructor(private readonly discordMessageService: DiscordMessageService) {} + constructor( + private readonly discordMessageService: DiscordMessageService, + private readonly discordVoiceService: DiscordVoiceService, + ) {} handler( @Payload() dto: TrackRequestDto, executionContext: TransformedCommandExecutionContext, ): | string - | void - | MessagePayload | InteractionReplyOptions - | Promise { + | Promise { const guildMember = executionContext.interaction.member as GuildMember; - 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', - }), - ], - }; + const joinVoiceChannel = + this.discordVoiceService.tryJoinChannelAndEstablishVoiceConnection( + guildMember, + ); + + if (!joinVoiceChannel.success) { + return joinVoiceChannel.reply; } - const channel = guildMember.voice.channel; - - joinVoiceChannel({ - channelId: channel.id, - adapterCreator: channel.guild.voiceAdapterCreator, - guildId: channel.guildId, - }); - - 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(); + this.discordVoiceService.playResource(createAudioResource(dto.search)); return { - embeds: [new EmbedBuilder().setTitle(`Playing ${dto.search}`).toJSON()], + embeds: [ + this.discordMessageService.buildMessage({ + title: `Playing ${dto.search}`, + }), + ], }; } } diff --git a/src/commands/search.comands.ts b/src/commands/search.comands.ts index 4a1e5d9..a6dc466 100644 --- a/src/commands/search.comands.ts +++ b/src/commands/search.comands.ts @@ -8,12 +8,12 @@ import { TransformedCommandExecutionContext, UsePipes, } from '@discord-nestjs/core'; -import { SearchHint } from '@jellyfin/sdk/lib/generated-client/models'; import { Logger } from '@nestjs/common/services'; import { ComponentType, EmbedBuilder, Events, + GuildMember, Interaction, InteractionReplyOptions, } from 'discord.js'; @@ -21,11 +21,12 @@ import { JellyfinSearchService } from '../clients/jellyfin/jellyfin.search.servi import { TrackRequestDto } from '../models/track-request.dto'; import { DefaultJellyfinColor } from '../types/colors'; -import { v4 as uuidv4 } from 'uuid'; import { DiscordMessageService } from '../clients/discord/discord.message.service'; +import { createAudioResource } from '@discordjs/voice'; import { formatDuration, intervalToDuration } from 'date-fns'; -import { format } from 'path'; +import { DiscordVoiceService } from '../clients/discord/discord.voice.service'; +import { JellyfinStreamBuilderService } from '../clients/jellyfin/jellyfin.stream.builder.service'; import { PlaybackService } from '../playback/playback.service'; @Command({ @@ -41,7 +42,9 @@ export class SearchItemCommand constructor( private readonly jellyfinSearchService: JellyfinSearchService, private readonly discordMessageService: DiscordMessageService, + private readonly discordVoiceService: DiscordVoiceService, private readonly playbackService: PlaybackService, + private readonly jellyfinStreamBuilder: JellyfinStreamBuilderService, ) {} async handler( @@ -149,6 +152,20 @@ export class SearchItemCommand durationInMilliseconds: milliseconds, }); + const guildMember = interaction.member as GuildMember; + const bitrate = guildMember.voice.channel.bitrate; + + this.discordVoiceService.tryJoinChannelAndEstablishVoiceConnection( + guildMember, + ); + + this.jellyfinStreamBuilder + .buildStreamUrl(item.Id, bitrate) + .then((stream) => { + const resource = createAudioResource(stream); + this.discordVoiceService.playResource(resource); + }); + await interaction.update({ embeds: [ new EmbedBuilder() diff --git a/src/models/generic-try-handler.ts b/src/models/generic-try-handler.ts new file mode 100644 index 0000000..a8e5ab4 --- /dev/null +++ b/src/models/generic-try-handler.ts @@ -0,0 +1,9 @@ +import { InteractionReplyOptions } from 'discord.js'; + +export interface GenericTryHandler { + success: boolean; + reply: + | string + | InteractionReplyOptions + | Promise; +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 060e903..e3ec416 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -3,4 +3,9 @@ export const Constants = { Version: '0.0.1', ApplicationName: 'Discord Jellyfin Music Bot', }, + Links: { + SourceCode: 'https://github.com/manuel-rw/jellyfin-discord-music-bot/', + ReportIssue: + 'https://github.com/manuel-rw/jellyfin-discord-music-bot/issues/new/choose', + }, };