major commit

This commit is contained in:
root 2024-05-19 23:23:05 +00:00
parent e8bc3427c5
commit 12b5526218
27 changed files with 11356 additions and 1130 deletions

View File

@ -4,6 +4,4 @@ RUN apk add ffmpeg
COPY . /app
WORKDIR /app
EXPOSE 3000
CMD ["yarn", "start:prod"]

9896
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "jellyfin-discord-music-bot",
"version": "0.1.1",
"version": "1.1.2",
"description": "A simple and leightweight Discord Bot, that integrates with your Jellyfin Media server and enables you to listen to your favourite music directly from discord.",
"author": "manuel-rw",
"private": true,
@ -37,8 +37,10 @@
"@nestjs/terminus": "^9.2.2",
"date-fns": "^2.30.0",
"discord.js": "^14.11.0",
"ffmpeg": "^0.0.4",
"joi": "^17.12.3",
"libsodium-wrappers": "^0.7.10",
"nest": "^0.1.6",
"opusscript": "^0.1.1",
"reflect-metadata": "^0.1.14",
"rimraf": "^5.0.1",

View File

@ -1,6 +1,6 @@
import { DiscordModule } from '@discord-nestjs/core';
import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { Injectable, Logger, Module, OnModuleInit } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule';
@ -60,7 +60,6 @@ export class AppModule implements OnModuleInit {
if (!variables.ALLOW_EVERYONE_FOR_DEFAULT_PERMS) {
return;
}
this.logger.warn(
'WARNING: You are using a potentially dangerous configuration: Everyone on your server has access to your bot. Ensure, that your bot is properly secured. Disable this by setting the environment variable ALLOW_EVERYONE to false',
);

View File

@ -1,15 +1,20 @@
import { Module } from '@nestjs/common';
import { OnModuleDestroy } from '@nestjs/common/interfaces/hooks';
// discord.module.ts
import { Module, OnModuleDestroy } from '@nestjs/common';
import { JellyfinClientModule } from '../jellyfin/jellyfin.module';
import { PlaybackModule } from '../../playback/playback.module';
import { DiscordStatusModule } from './discord.status.module'; // Adjust the path as necessary
import { DiscordConfigService } from './discord.config.service';
import { DiscordMessageService } from './discord.message.service';
import { DiscordVoiceService } from './discord.voice.service';
@Module({
imports: [PlaybackModule, JellyfinClientModule],
imports: [
PlaybackModule,
JellyfinClientModule,
DiscordStatusModule, // Import DiscordStatusModule here
],
controllers: [],
providers: [DiscordConfigService, DiscordVoiceService, DiscordMessageService],
exports: [DiscordConfigService, DiscordVoiceService, DiscordMessageService],

View File

@ -0,0 +1,11 @@
// discord-status.module.ts
import { Module } from '@nestjs/common';
import StatusService from './discord.status.service';
import { DiscordModule } from '@discord-nestjs/core';
@Module({
imports: [DiscordModule.forFeature()],
providers: [StatusService],
exports: [StatusService],
})
export class DiscordStatusModule {}

View File

@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import { InjectDiscordClient } from '@discord-nestjs/core';
import { Client, ActivityType } from 'discord.js';
@Injectable()
export class StatusService {
constructor(
@InjectDiscordClient()
private readonly client: Client,
) {}
updateStatus(text: string): void {
this.client.user?.setPresence({
activities: [{
name: text,
type: ActivityType.Listening,
}],
status: 'dnd',
});
}
clearStatus(): void {
this.client.user?.setPresence({
activities: [],
status: 'idle',
});
}
}
export default StatusService;

View File

@ -16,15 +16,14 @@ import { Injectable } from '@nestjs/common';
import { Logger } from '@nestjs/common/services';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import { Interval } from '@nestjs/schedule';
import { APIEmbed, GuildMember, InteractionEditReplyOptions, InteractionReplyOptions, MessagePayload } from 'discord.js';
import { APIEmbed, ChannelType, Guild, GuildMember, InteractionEditReplyOptions, InteractionReplyOptions, MessagePayload, VoiceChannel } from 'discord.js';
import { TryResult } from '../../models/TryResult';
import { Track } from '../../models/music/Track';
import { PlaybackService } from '../../playback/playback.service';
import { JellyfinStreamBuilderService } from '../jellyfin/jellyfin.stream.builder.service';
import { JellyfinWebSocketService } from '../jellyfin/jellyfin.websocket.service';
import { StatusService } from './discord.status.service';
import { DiscordMessageService } from './discord.message.service';
@Injectable()
@ -40,8 +39,8 @@ export class DiscordVoiceService {
private readonly jellyfinWebSocketService: JellyfinWebSocketService,
private readonly jellyfinStreamBuilder: JellyfinStreamBuilderService,
private readonly eventEmitter: EventEmitter2,
private readonly statusService: StatusService
) {}
@OnEvent('internal.audio.track.announce')
handleOnNewTrack(track: Track) {
const resource = createAudioResource(
@ -50,6 +49,9 @@ export class DiscordVoiceService {
inlineVolume: true,
},
);
this.statusService.updateStatus(track.name);
this.logger.log(track.remoteImages);
this.logger.log(`Stream URL: ${track.getStreamUrl(this.jellyfinStreamBuilder)}`);
this.playResource(resource);
}
@ -109,6 +111,48 @@ export class DiscordVoiceService {
};
}
async tryJoinChannelByIdAndEstablishVoiceConnection(
guild: Guild,
channelId: string,
): Promise<TryResult<InteractionReplyOptions>> {
const channel = guild.channels.cache.get(channelId) as VoiceChannel;
if (!channel || channel.type !== ChannelType.GuildVoice) {
return {
success: false,
reply: {
embeds: [
this.discordMessageService.buildMessage({
title: 'Unable to join the specified channel',
description: 'Invalid channel ID or the channel is not a voice channel.',
}),
],
},
};
}
joinVoiceChannel({
channelId: channel.id,
adapterCreator: channel.guild.voiceAdapterCreator,
guildId: channel.guild.id,
});
this.jellyfinWebSocketService.initializeAndConnect();
if (this.voiceConnection === undefined) {
this.voiceConnection = getVoiceConnection(guild.id);
}
this.voiceConnection?.on(VoiceConnectionStatus.Disconnected, () => {
if (this.voiceConnection !== undefined) {
const playlist = this.playbackService.getPlaylistOrDefault().clear();
this.disconnect();
}
});
return {
success: true,
reply: {},
};
}
changeVolume(volume: number) {
if (!this.audioResource || !this.audioResource.volume) {
this.logger.error(
@ -128,7 +172,6 @@ export class DiscordVoiceService {
this.createAndReturnOrGetAudioPlayer().play(resource);
this.audioResource = resource;
}
/**
* Pauses the current audio player
*/
@ -153,6 +196,7 @@ export class DiscordVoiceService {
this.eventEmitter.emit('internal.audio.track.finish', playlist.getActiveTrack());
playlist.clear();
}
this.statusService.clearStatus();
return hasStopped;
}
@ -333,6 +377,7 @@ export class DiscordVoiceService {
if (!hasNextTrack) {
this.logger.debug('Reached the end of the playlist');
this.statusService.clearStatus();
return;
}

View File

@ -28,8 +28,8 @@ export class JellyfinService {
version: Constants.Metadata.Version.All(),
},
deviceInfo: {
id: 'jellyfin-discord-bot',
name: 'Jellyfin Discord Bot',
id: 'hangout-moosich',
name: 'discord-bot',
},
});

View File

@ -13,7 +13,7 @@ import { PreviousTrackCommand } from './previous.command';
import { SkipTrackCommand } from './next.command';
import { StatusCommand } from './status.command';
import { StopPlaybackCommand } from './stop.command';
import { SummonCommand } from './summon.command';
import { SummonCommand } from './summon/summon.commands';
import { PlaylistInteractionCollector } from './playlist/playlist.interaction-collector';
import { EnqueueRandomItemsCommand } from './random/random.command';
import { VolumeCommand } from './volume/volume.command';

View File

@ -11,8 +11,8 @@ import { PlaybackService } from 'src/playback/playback.service';
@Injectable()
@Command({
name: 'disconnect',
description: 'Join your current voice channel',
name: 'leave',
description: 'Leave channel and clear the current playlist',
defaultMemberPermissions,
})
export class DisconnectCommand {
@ -24,7 +24,8 @@ export class DisconnectCommand {
@Handler()
async handler(@IA() interaction: CommandInteraction): Promise<void> {
await interaction.reply({
await interaction.deferReply({ ephemeral: true });
await interaction.editReply({
embeds: [
this.discordMessageService.buildMessage({
title: 'Disconnecting...',
@ -49,7 +50,7 @@ export class DisconnectCommand {
await interaction.editReply({
embeds: [
this.discordMessageService.buildMessage({
title: 'Disconnected from your channel',
title: 'Disconnected.',
}),
],
});

View File

@ -23,16 +23,10 @@ export class HelpCommand {
this.discordMessageService.buildMessage({
title: 'Jellyfin Discord Bot',
description:
'Jellyfin Discord Bot is an open source and self-hosted Discord bot, that integrates with your Jellyfin Media server and enables you to playback music from your libraries. You can use the Discord Slash Commands to invoke bot commands.',
authorUrl: 'https://github.com/manuel-rw/jellyfin-discord-music-bot',
'Open source music bot',
authorUrl: 'https://jellyfin.org',
mixin(embedBuilder) {
return embedBuilder.addFields([
{
name: 'Report an issue',
value:
'https://github.com/manuel-rw/jellyfin-discord-music-bot/issues/new/choose',
inline: true,
},
{
name: 'Source code',
value:

View File

@ -22,8 +22,9 @@ export class SkipTrackCommand {
@Handler()
async handler(@IA() interaction: CommandInteraction): Promise<void> {
await interaction.deferReply({ ephemeral: true });
if (!this.playbackService.getPlaylistOrDefault().hasActiveTrack()) {
await interaction.reply({
await interaction.editReply({
embeds: [
this.discordMessageService.buildErrorMessage({
title: 'There is no next track',
@ -34,10 +35,10 @@ export class SkipTrackCommand {
}
this.playbackService.getPlaylistOrDefault().setNextTrackAsActiveTrack();
await interaction.reply({
await interaction.editReply({
embeds: [
this.discordMessageService.buildMessage({
title: 'Skipped to the next track',
title: 'Went to next track',
}),
],
});

View File

@ -7,6 +7,7 @@ import { CommandInteraction } from 'discord.js';
import { DiscordMessageService } from '../clients/discord/discord.message.service';
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
import { defaultMemberPermissions } from '../utils/environment';
import { PlaybackService } from 'src/playback/playback.service';
@Injectable()
@Command({
@ -18,13 +19,27 @@ export class PausePlaybackCommand {
constructor(
private readonly discordVoiceService: DiscordVoiceService,
private readonly discordMessageService: DiscordMessageService,
private readonly playbackService: PlaybackService,
) {}
@Handler()
async handler(@IA() interaction: CommandInteraction): Promise<void> {
// Check if the bot is in a voice channel
await interaction.deferReply({ ephemeral: true });
if (!this.playbackService.getPlaylistOrDefault().hasActiveTrack()) {
await interaction.editReply({
embeds: [
this.discordMessageService.buildErrorMessage({
title: "Unable to change playback state",
description:
'The bot is not playing any music.',
}),
],
});
return;
}
const shouldBePaused = this.discordVoiceService.togglePaused();
await interaction.reply({
await interaction.editReply({
embeds: [
this.discordMessageService.buildMessage({
title: shouldBePaused ? 'Paused' : 'Unpaused',

View File

@ -79,7 +79,7 @@ export class PlaylistCommand {
await interaction.editReply({
components: [],
});
}, 60 * 1000);
}, 360 * 1000);
}
private getChunks() {
@ -140,7 +140,7 @@ export class PlaylistCommand {
embeds: [
this.discordMessageService.buildMessage({
title: 'Page does not exist',
description: 'Please pass a valid page',
description: 'Pass a valid page',
}),
],
ephemeral: true,

View File

@ -22,8 +22,9 @@ export class PreviousTrackCommand {
@Handler()
async handler(@IA() interaction: CommandInteraction): Promise<void> {
await interaction.deferReply({ ephemeral: true });
if (!this.playbackService.getPlaylistOrDefault().hasActiveTrack()) {
await interaction.reply({
await interaction.editReply({
embeds: [
this.discordMessageService.buildErrorMessage({
title: 'There is no previous track',
@ -34,7 +35,7 @@ export class PreviousTrackCommand {
}
this.playbackService.getPlaylistOrDefault().setPreviousTrackAsActiveTrack();
await interaction.reply({
await interaction.editReply({
embeds: [
this.discordMessageService.buildMessage({
title: 'Went to previous track',

View File

@ -19,7 +19,7 @@ import { Constants } from '../utils/constants';
@Command({
name: 'status',
description: 'Display the current status for troubleshooting',
description: 'Display helpful information.',
defaultMemberPermissions: 'ViewChannel',
})
@Injectable()
@ -65,7 +65,7 @@ export class StatusCommand {
inline: true,
},
{
name: 'Discord Bot Ping',
name: 'Ping to Discord endpoint',
value: `${ping}ms`,
inline: true,
},
@ -78,18 +78,7 @@ export class StatusCommand {
name: 'Discord Bot Uptime',
value: `${formattedDuration}`,
inline: false,
},
{
name: 'Jellyfin Server Version',
value: jellyfinSystemInformation.data.Version ?? 'unknown',
inline: true,
},
{
name: 'Jellyfin Server Operating System',
value:
jellyfinSystemInformation.data.OperatingSystem ?? 'unknown',
inline: true,
},
}
]);
},
}),

View File

@ -30,7 +30,7 @@ export class StopPlaybackCommand {
await interaction.reply({
embeds: [
this.discordMessageService.buildErrorMessage({
title: 'Unable to stop when nothing is playing'
title: 'Nothing is playing you silly goose'
}),
],
});

View File

@ -1,17 +1,14 @@
import { Command, Handler, IA } from '@discord-nestjs/core';
import { Injectable, Logger } from '@nestjs/common';
import { CommandInteraction, GuildMember } from 'discord.js';
import { DiscordMessageService } from '../clients/discord/discord.message.service';
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
import { defaultMemberPermissions } from '../utils/environment';
@Injectable()
@Command({
name: 'summon',
description: 'Join your current voice channel',
name: 'join',
description: 'Join a specified voice channel or your current voice channel',
defaultMemberPermissions,
})
export class SummonCommand {
@ -26,24 +23,44 @@ export class SummonCommand {
async handler(@IA() interaction: CommandInteraction): Promise<void> {
await interaction.deferReply();
// Adjusted to check versions and access patterns
let channelId: string | null = null;
if (interaction.options.get) {
// For discord.js v13+
const channelOption = interaction.options.get('channel_id');
channelId = channelOption ? channelOption.value as string : null;
} else {
// Fallback for older versions or unexpected behavior
console.error('Unable to access interaction options using standard methods.');
}
const guildMember = interaction.member as GuildMember;
const tryResult =
this.discordVoiceService.tryJoinChannelAndEstablishVoiceConnection(
let tryResult;
if (channelId) {
tryResult = await this.discordVoiceService.tryJoinChannelByIdAndEstablishVoiceConnection(
guildMember.guild,
channelId
);
} else {
tryResult = await this.discordVoiceService.tryJoinChannelAndEstablishVoiceConnection(
guildMember,
);
}
if (!tryResult.success) {
await interaction.editReply(tryResult.reply);
return;
}
// Get channel name and adjust message
const channelName = tryResult.voiceChannel.name;
this.logger.debug(`Joined voice channel '${channelName}'`);
await interaction.editReply({
embeds: [
this.discordMessageService.buildMessage({
title: 'Joined your voicehannel',
title: `Joined voice channel '${channelName}'`,
description:
"I'm ready to play media. Use ``Cast to device`` in Jellyfin or the ``/play`` command to get started.",
"",
}),
],
});

View File

@ -0,0 +1,74 @@
// summon.command.ts
import { SlashCommandPipe } from '@discord-nestjs/common';
import { Command, Handler, IA, InteractionEvent } from '@discord-nestjs/core';
import { Injectable, Logger } from '@nestjs/common';
import { CommandInteraction, GuildMember } from 'discord.js';
import { DiscordMessageService } from '../../clients/discord/discord.message.service';
import { DiscordVoiceService } from '../../clients/discord/discord.voice.service';
import { defaultMemberPermissions } from '../../utils/environment';
import { SummonParams } from './summon.params';
@Injectable()
@Command({
name: 'join',
description: 'Join a specified voice channel or your current voice channel',
defaultMemberPermissions,
})
export class SummonCommand {
private readonly logger = new Logger(SummonCommand.name);
constructor(
private readonly discordVoiceService: DiscordVoiceService,
private readonly discordMessageService: DiscordMessageService,
) {}
@Handler()
async handler(
@InteractionEvent(SlashCommandPipe) dto: SummonParams,
@IA() interaction: CommandInteraction,
): Promise<void> {
await interaction.deferReply({ ephemeral: true });
const guildMember = interaction.member as GuildMember;
let tryResult;
if (dto.channelId) {
tryResult = await this.discordVoiceService.tryJoinChannelByIdAndEstablishVoiceConnection(
guildMember.guild,
dto.channelId
);
} else {
tryResult = await this.discordVoiceService.tryJoinChannelAndEstablishVoiceConnection(
guildMember
);
}
if (!tryResult.success) {
await interaction.editReply(tryResult.reply);
return;
}
if (dto.channelId) {
this.logger.debug(`Joined voice channel ${dto.channelId?.toString()}`);
await interaction.editReply({
embeds: [
this.discordMessageService.buildMessage({
title: `Joined voice channel '${dto.channelId?.toString()}'`,
description: "I'm ready to play music!"
}),
],
});
} else {
this.logger.debug(`Joined voice channel ${guildMember.voice.channelId}`);
await interaction.editReply({
embeds: [
this.discordMessageService.buildMessage({
title: `Joined voice channel '${guildMember.voice.channelId}'`,
description: "I'm ready to play music!"
}),
],
});
}
}
}

View File

@ -0,0 +1,13 @@
// summon.params.ts
import { Param, ParamType } from '@discord-nestjs/core';
export class SummonParams {
@Param({
name: 'channel_id',
description: 'The ID of the voice channel to join',
type: ParamType.STRING,
required: false,
})
channelId?: string;
}

View File

@ -11,6 +11,7 @@ import { PlaybackService } from 'src/playback/playback.service';
import { sleepAsync } from '../../utils/timeUtils';
import { VolumeCommandParams } from './volume.params';
import { defaultMemberPermissions } from '../../utils/environment';
import { time } from 'console';
@Injectable()
@Command({
@ -32,7 +33,7 @@ export class VolumeCommand {
@InteractionEvent(SlashCommandPipe) dto: VolumeCommandParams,
@IA() interaction: CommandInteraction,
): Promise<void> {
await interaction.deferReply();
await interaction.deferReply({ ephemeral: true });
if (!this.playbackService.getPlaylistOrDefault().hasActiveTrack()) {
await interaction.editReply({
@ -54,16 +55,37 @@ export class VolumeCommand {
);
this.discordVoiceService.changeVolume(volume);
// Create embed to start volume change
await interaction.editReply({
embeds: [
this.discordMessageService.buildMessage({
title: `Changing volume to ${dto.volume.toFixed(0)}%`,
description:
'This may take a few seconds',
}),
],
});
// Discord takes some time to react. Confirmation message should appear after actual change
await sleepAsync(1500);
// Warn the user if the volume is too high
if (dto.volume > 200) {
await interaction.editReply({
embeds: [
this.discordMessageService.buildMessage({
title: `Volume set to ${dto.volume.toFixed(0)}%`,
description:
'You are playing with fire. Be careful with your ears.',
}),
],
});
return;
}
await interaction.editReply({
embeds: [
this.discordMessageService.buildMessage({
title: `Sucessfully set volume to ${dto.volume.toFixed(0)}%`,
description:
'Updating may take a few seconds to take effect.\nPlease note that listening at a high volume for a long time may damage your hearing',
'Take care of your ears.',
}),
],
});

View File

@ -6,7 +6,6 @@ export class VolumeCommandParams {
description: 'The desired volume',
type: ParamType.INTEGER,
minValue: 0,
maxValue: 150,
})
volume: number;
}

View File

@ -1,8 +1,10 @@
import { LogLevel } from '@nestjs/common/services';
import { NestFactory } from '@nestjs/core';
import { INestApplication } from '@nestjs/common';
import { AppModule } from './app.module';
import { INestApplication } from '@nestjs/common';
import { StatusService } from './clients/discord/discord.status.service';
import { time } from 'console';
function getLoggingLevels(): LogLevel[] {
if (!process.env.LOG_LEVEL) {
@ -21,7 +23,7 @@ function getLoggingLevels(): LogLevel[] {
case 'verbose':
return ['error', 'warn', 'log', 'debug', 'verbose'];
default:
console.log(`failed to process log level ${process.env.LOG_LEVEL}`);
console.log(`Failed to process log level ${process.env.LOG_LEVEL}`);
return ['error', 'warn', 'log'];
}
}
@ -32,7 +34,14 @@ async function bootstrap() {
app = await NestFactory.create(AppModule, {
logger: getLoggingLevels(),
});
// Retrieve the StatusService from the app's dependency injector
const statusService = app.get(StatusService);
// Call clearStatus on the StatusService instance
app.enableShutdownHooks();
await app.listen(process.env.PORT || 3000);
await app.listen(3002);
statusService.clearStatus();
}
bootstrap();

View File

@ -4,7 +4,6 @@ import {
} from '@jellyfin/sdk/lib/generated-client/models';
import { JellyfinStreamBuilderService } from '../../clients/jellyfin/jellyfin.stream.builder.service';
export class Track {
/**
* The identifier of this track, structured as a UID.
@ -50,7 +49,7 @@ export class Track {
}
getStreamUrl(streamBuilder: JellyfinStreamBuilderService) {
return streamBuilder.buildStreamUrl(this.id, 96000);
return streamBuilder.buildStreamUrl(this.id, 320000);
}
getRemoteImages(): RemoteImageInfo[] {

View File

@ -1,13 +1,13 @@
export const Constants = {
Metadata: {
Version: {
Major: 0,
Minor: 1,
Patch: 1,
Major: 1,
Minor: 2,
Patch: 5,
All: () =>
`${Constants.Metadata.Version.Major}.${Constants.Metadata.Version.Minor}.${Constants.Metadata.Version.Patch}`,
},
ApplicationName: 'Discord Jellyfin Music Bot',
ApplicationName: 'Moosich-Hangout',
},
Links: {
SourceCode: 'https://github.com/manuel-rw/jellyfin-discord-music-bot/',

2203
yarn.lock

File diff suppressed because it is too large Load Diff