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 { APIEmbed, EmbedBuilder } from 'discord.js';
import { DefaultJellyfinColor, ErrorJellyfinColor } from '../../types/colors'; import { DefaultJellyfinColor, ErrorJellyfinColor } from '../../types/colors';
import { formatRFC7231 } from 'date-fns';
import { Constants } from '../../utils/constants';
@Injectable() @Injectable()
export class DiscordMessageService { export class DiscordMessageService {
buildErrorMessage({ buildErrorMessage({
@ -11,40 +14,48 @@ export class DiscordMessageService {
title: string; title: string;
description?: string; description?: string;
}): APIEmbed { }): APIEmbed {
const embedBuilder = new EmbedBuilder() const date = formatRFC7231(new Date());
.setColor(ErrorJellyfinColor) return this.buildMessage({
.setAuthor({ title: title,
name: title, description: description,
iconURL: mixin(embedBuilder) {
'https://github.com/manuel-rw/jellyfin-discord-music-bot/blob/nestjs-migration/images/icons/alert-circle.png?raw=true', return embedBuilder
.setFooter({
text: `${date} - Report an issue: ${Constants.Links.ReportIssue}`,
})
.setColor(ErrorJellyfinColor);
},
}); });
if (description !== undefined) {
embedBuilder.setDescription(description);
}
return embedBuilder.toJSON();
} }
buildMessage({ buildMessage({
title, title,
description, description,
mixin = (builder) => builder,
}: { }: {
title: string; title: string;
description?: string; description?: string;
mixin?: (embedBuilder: EmbedBuilder) => EmbedBuilder;
}): APIEmbed { }): APIEmbed {
const embedBuilder = new EmbedBuilder() const date = formatRFC7231(new Date());
let embedBuilder = new EmbedBuilder()
.setColor(DefaultJellyfinColor) .setColor(DefaultJellyfinColor)
.setAuthor({ .setAuthor({
name: title, name: title,
iconURL: iconURL:
'https://github.com/manuel-rw/jellyfin-discord-music-bot/blob/nestjs-migration/images/icons/circle-check.png?raw=true', '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) { if (description !== undefined && description.length > 0) {
embedBuilder.setDescription(description); embedBuilder = embedBuilder.setDescription(description);
} }
embedBuilder = mixin(embedBuilder);
return embedBuilder.toJSON(); return embedBuilder.toJSON();
} }
} }

View File

@ -8,7 +8,7 @@ import { DiscordVoiceService } from './discord.voice.service';
imports: [], imports: [],
controllers: [], controllers: [],
providers: [DiscordConfigService, DiscordVoiceService, DiscordMessageService], providers: [DiscordConfigService, DiscordVoiceService, DiscordMessageService],
exports: [DiscordConfigService, DiscordMessageService], exports: [DiscordConfigService, DiscordVoiceService, DiscordMessageService],
}) })
export class DiscordClientModule implements OnModuleDestroy { export class DiscordClientModule implements OnModuleDestroy {
constructor(private readonly discordVoiceService: DiscordVoiceService) {} 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 { Injectable } from '@nestjs/common';
import { Logger } from '@nestjs/common/services'; 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() @Injectable()
export class DiscordVoiceService { export class DiscordVoiceService {
private readonly logger = new Logger(DiscordVoiceService.name); 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() { disconnectGracefully() {
const connections = getVoiceConnections(); const connections = getVoiceConnections();
this.logger.debug( this.logger.debug(
@ -17,4 +88,23 @@ export class DiscordVoiceService {
connection.destroy(); 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 { Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { JellyfinSearchService } from './jellyfin.search.service'; import { JellyfinSearchService } from './jellyfin.search.service';
import { JellyfinService } from './jellyfin.service'; import { JellyfinService } from './jellyfin.service';
import { JellyfinStreamBuilderService } from './jellyfin.stream.builder.service';
import { JellyinWebsocketService } from './jellyfin.websocket.service'; import { JellyinWebsocketService } from './jellyfin.websocket.service';
@Module({ @Module({
imports: [], imports: [],
controllers: [], controllers: [],
providers: [JellyfinService, JellyinWebsocketService, JellyfinSearchService], providers: [
exports: [JellyfinService, JellyfinSearchService], JellyfinService,
JellyinWebsocketService,
JellyfinSearchService,
JellyfinStreamBuilderService,
],
exports: [
JellyfinService,
JellyfinSearchService,
JellyfinStreamBuilderService,
],
}) })
export class JellyfinClientModule implements OnModuleInit, OnModuleDestroy { export class JellyfinClientModule implements OnModuleInit, OnModuleDestroy {
constructor( 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 { Module } from '@nestjs/common';
import { DiscordMessageService } from '../clients/discord/discord.message.service'; import { DiscordMessageService } from '../clients/discord/discord.message.service';
import { DiscordClientModule } from '../clients/discord/discord.module';
import { JellyfinClientModule } from '../clients/jellyfin/jellyfin.module'; import { JellyfinClientModule } from '../clients/jellyfin/jellyfin.module';
import { PlaybackService } from '../playback/playback.service'; import { PlaybackService } from '../playback/playback.service';
import { CurrentTrackCommand } from './current.command'; import { CurrentTrackCommand } from './current.command';
@ -17,7 +18,11 @@ import { StopPlaybackCommand } from './stop.command';
import { SummonCommand } from './summon.command'; import { SummonCommand } from './summon.command';
@Module({ @Module({
imports: [DiscordModule.forFeature(), JellyfinClientModule], imports: [
DiscordModule.forFeature(),
JellyfinClientModule,
DiscordClientModule,
],
controllers: [], controllers: [],
providers: [ providers: [
HelpCommand, HelpCommand,

View File

@ -2,24 +2,39 @@ import { TransformPipe } from '@discord-nestjs/common';
import { import {
Command, Command,
CommandExecutionContext,
DiscordCommand,
DiscordTransformedCommand, DiscordTransformedCommand,
TransformedCommandExecutionContext, TransformedCommandExecutionContext,
UsePipes, UsePipes,
} from '@discord-nestjs/core'; } from '@discord-nestjs/core';
import { InteractionReplyOptions } from 'discord.js'; import {
ButtonInteraction,
CacheType,
ChatInputCommandInteraction,
ContextMenuCommandInteraction,
Interaction,
InteractionReplyOptions,
MessagePayload,
StringSelectMenuInteraction,
} from 'discord.js';
@Command({ @Command({
name: 'pause', name: 'pause',
description: 'Pause or resume the playback of the current track', description: 'Pause or resume the playback of the current track',
}) })
@UsePipes(TransformPipe) @UsePipes(TransformPipe)
export class PausePlaybackCommand export class PausePlaybackCommand implements DiscordCommand {
implements DiscordTransformedCommand<unknown>
{
handler( handler(
dto: unknown, interaction:
executionContext: TransformedCommandExecutionContext<any>, | ChatInputCommandInteraction<CacheType>
): InteractionReplyOptions | string { | ContextMenuCommandInteraction<CacheType>,
return 'nice'; executionContext: CommandExecutionContext<
StringSelectMenuInteraction<CacheType> | ButtonInteraction<CacheType>
>,
): string | InteractionReplyOptions {
return {
content: 'test',
};
} }
} }

View File

@ -6,23 +6,14 @@ import {
TransformedCommandExecutionContext, TransformedCommandExecutionContext,
UsePipes, UsePipes,
} from '@discord-nestjs/core'; } from '@discord-nestjs/core';
import { import { GuildMember, InteractionReplyOptions } from 'discord.js';
EmbedBuilder,
GuildMember,
InteractionReplyOptions,
MessagePayload,
} from 'discord.js';
import { createAudioResource } from '@discordjs/voice';
import { Injectable } from '@nestjs/common'; 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 { Logger } from '@nestjs/common/services';
import { DiscordMessageService } from '../clients/discord/discord.message.service'; import { DiscordMessageService } from '../clients/discord/discord.message.service';
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
import { TrackRequestDto } from '../models/track-request.dto';
@Command({ @Command({
name: 'play', name: 'play',
@ -33,64 +24,37 @@ import { DiscordMessageService } from '../clients/discord/discord.message.servic
export class PlayCommand implements DiscordTransformedCommand<TrackRequestDto> { export class PlayCommand implements DiscordTransformedCommand<TrackRequestDto> {
private readonly logger = new Logger(PlayCommand.name); private readonly logger = new Logger(PlayCommand.name);
constructor(private readonly discordMessageService: DiscordMessageService) {} constructor(
private readonly discordMessageService: DiscordMessageService,
private readonly discordVoiceService: DiscordVoiceService,
) {}
handler( handler(
@Payload() dto: TrackRequestDto, @Payload() dto: TrackRequestDto,
executionContext: TransformedCommandExecutionContext<any>, executionContext: TransformedCommandExecutionContext<any>,
): ):
| string | string
| void
| MessagePayload
| InteractionReplyOptions | InteractionReplyOptions
| Promise<string | void | MessagePayload | InteractionReplyOptions> { | Promise<string | InteractionReplyOptions> {
const guildMember = executionContext.interaction.member as GuildMember; const guildMember = executionContext.interaction.member as GuildMember;
if (guildMember.voice.channel === null) { const joinVoiceChannel =
return { this.discordVoiceService.tryJoinChannelAndEstablishVoiceConnection(
embeds: [ guildMember,
this.discordMessageService.buildErrorMessage({ );
title: 'Unable to join your channel',
description: if (!joinVoiceChannel.success) {
'You are in a channel, I am either unabelt to connect to or you aren&apost in a channel yet', 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 { return {
embeds: [ embeds: [
this.discordMessageService.buildErrorMessage({ this.discordMessageService.buildMessage({
title: 'Unable to establish audio connection', title: `Playing ${dto.search}`,
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 {
embeds: [new EmbedBuilder().setTitle(`Playing ${dto.search}`).toJSON()],
};
}
} }

View File

@ -8,12 +8,12 @@ import {
TransformedCommandExecutionContext, TransformedCommandExecutionContext,
UsePipes, UsePipes,
} from '@discord-nestjs/core'; } from '@discord-nestjs/core';
import { SearchHint } from '@jellyfin/sdk/lib/generated-client/models';
import { Logger } from '@nestjs/common/services'; import { Logger } from '@nestjs/common/services';
import { import {
ComponentType, ComponentType,
EmbedBuilder, EmbedBuilder,
Events, Events,
GuildMember,
Interaction, Interaction,
InteractionReplyOptions, InteractionReplyOptions,
} from 'discord.js'; } from 'discord.js';
@ -21,11 +21,12 @@ import { JellyfinSearchService } from '../clients/jellyfin/jellyfin.search.servi
import { TrackRequestDto } from '../models/track-request.dto'; import { TrackRequestDto } from '../models/track-request.dto';
import { DefaultJellyfinColor } from '../types/colors'; import { DefaultJellyfinColor } from '../types/colors';
import { v4 as uuidv4 } from 'uuid';
import { DiscordMessageService } from '../clients/discord/discord.message.service'; import { DiscordMessageService } from '../clients/discord/discord.message.service';
import { createAudioResource } from '@discordjs/voice';
import { formatDuration, intervalToDuration } from 'date-fns'; 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'; import { PlaybackService } from '../playback/playback.service';
@Command({ @Command({
@ -41,7 +42,9 @@ export class SearchItemCommand
constructor( constructor(
private readonly jellyfinSearchService: JellyfinSearchService, private readonly jellyfinSearchService: JellyfinSearchService,
private readonly discordMessageService: DiscordMessageService, private readonly discordMessageService: DiscordMessageService,
private readonly discordVoiceService: DiscordVoiceService,
private readonly playbackService: PlaybackService, private readonly playbackService: PlaybackService,
private readonly jellyfinStreamBuilder: JellyfinStreamBuilderService,
) {} ) {}
async handler( async handler(
@ -149,6 +152,20 @@ export class SearchItemCommand
durationInMilliseconds: milliseconds, 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({ await interaction.update({
embeds: [ embeds: [
new EmbedBuilder() 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', Version: '0.0.1',
ApplicationName: 'Discord Jellyfin Music Bot', 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',
},
}; };