mirror of
https://github.com/informaticker/discord-jellyfin-bot.git
synced 2024-11-27 03:41:57 +01:00
✨ Add universal audio streaming endpoint
This commit is contained in:
parent
2aa2d16e40
commit
fe5096acf7
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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) {}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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(
|
||||
|
31
src/clients/jellyfin/jellyfin.stream.builder.service.ts
Normal file
31
src/clients/jellyfin/jellyfin.stream.builder.service.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
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}`,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
|
9
src/models/generic-try-handler.ts
Normal file
9
src/models/generic-try-handler.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { InteractionReplyOptions } from 'discord.js';
|
||||
|
||||
export interface GenericTryHandler {
|
||||
success: boolean;
|
||||
reply:
|
||||
| string
|
||||
| InteractionReplyOptions
|
||||
| Promise<string | InteractionReplyOptions>;
|
||||
}
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user