Add universal audio streaming endpoint

This commit is contained in:
Manuel Ruwe 2022-12-17 16:58:38 +01:00
parent 2aa2d16e40
commit fe5096acf7
11 changed files with 247 additions and 90 deletions

View File

@ -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',
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);
},
});
if (description !== undefined) {
embedBuilder.setDescription(description);
}
return embedBuilder.toJSON();
}
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();
}
}

View File

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

View File

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

View File

@ -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(

View File

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

View File

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

View File

@ -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<unknown>
{
export class PausePlaybackCommand implements DiscordCommand {
handler(
dto: unknown,
executionContext: TransformedCommandExecutionContext<any>,
): InteractionReplyOptions | string {
return 'nice';
interaction:
| ChatInputCommandInteraction<CacheType>
| ContextMenuCommandInteraction<CacheType>,
executionContext: CommandExecutionContext<
StringSelectMenuInteraction<CacheType> | ButtonInteraction<CacheType>
>,
): string | InteractionReplyOptions {
return {
content: 'test',
};
}
}

View File

@ -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<TrackRequestDto> {
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<any>,
):
| string
| void
| MessagePayload
| InteractionReplyOptions
| Promise<string | void | MessagePayload | InteractionReplyOptions> {
| Promise<string | InteractionReplyOptions> {
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;
this.discordVoiceService.playResource(createAudioResource(dto.search));
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',
this.discordMessageService.buildMessage({
title: `Playing ${dto.search}`,
}),
],
};
}
const player = createAudioPlayer();
const resource = createAudioResource(dto.search);
connection.subscribe(player);
player.play(resource);
player.unpause();
return {
embeds: [new EmbedBuilder().setTitle(`Playing ${dto.search}`).toJSON()],
};
}
}

View File

@ -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()

View File

@ -0,0 +1,9 @@
import { InteractionReplyOptions } from 'discord.js';
export interface GenericTryHandler {
success: boolean;
reply:
| string
| InteractionReplyOptions
| Promise<string | InteractionReplyOptions>;
}

View File

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