♻️ Playlist and playback manager (#89)

This commit is contained in:
Manuel 2023-02-19 21:02:13 +01:00 committed by GitHub
parent 6c75861c49
commit 3adec06df7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 356 additions and 341 deletions

View File

@ -22,7 +22,7 @@
},
"dependencies": {
"@discord-nestjs/common": "^5.1.5",
"@discord-nestjs/core": "^4.3.1",
"@discord-nestjs/core": "^5.3.0",
"@discordjs/opus": "^0.9.0",
"@discordjs/voice": "^0.14.0",
"@jellyfin/sdk": "^0.7.0",

View File

@ -1,9 +1,9 @@
import { registerFilterGlobally } from '@discord-nestjs/core';
import { Module } from '@nestjs/common';
import { OnModuleDestroy } from '@nestjs/common/interfaces/hooks';
import { CommandExecutionError } from '../../middleware/command-execution-filter';
import { PlaybackModule } from '../../playback/playback.module';
import { JellyfinClientModule } from '../jellyfin/jellyfin.module';
import { PlaybackModule } from '../../playback/playback.module';
import { DiscordConfigService } from './discord.config.service';
import { DiscordMessageService } from './discord.message.service';
import { DiscordVoiceService } from './discord.voice.service';
@ -11,15 +11,7 @@ import { DiscordVoiceService } from './discord.voice.service';
@Module({
imports: [PlaybackModule, JellyfinClientModule],
controllers: [],
providers: [
DiscordConfigService,
DiscordVoiceService,
DiscordMessageService,
{
provide: registerFilterGlobally(),
useClass: CommandExecutionError,
},
],
providers: [DiscordConfigService, DiscordVoiceService, DiscordMessageService],
exports: [DiscordConfigService, DiscordVoiceService, DiscordMessageService],
})
export class DiscordClientModule implements OnModuleDestroy {

View File

@ -9,14 +9,19 @@ import {
joinVoiceChannel,
VoiceConnection,
} from '@discordjs/voice';
import { Injectable } from '@nestjs/common';
import { Logger } from '@nestjs/common/services';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import { GuildMember } from 'discord.js';
import { JellyfinStreamBuilderService } from '../jellyfin/jellyfin.stream.builder.service';
import { JellyfinWebSocketService } from '../jellyfin/jellyfin.websocket.service';
import { GenericTryHandler } from '../../models/generic-try-handler';
import { PlaybackService } from '../../playback/playback.service';
import { Track } from '../../types/track';
import { JellyfinWebSocketService } from '../jellyfin/jellyfin.websocket.service';
import { GenericTrack } from '../../models/shared/GenericTrack';
import { DiscordMessageService } from './discord.message.service';
@Injectable()
@ -29,12 +34,15 @@ export class DiscordVoiceService {
private readonly discordMessageService: DiscordMessageService,
private readonly playbackService: PlaybackService,
private readonly jellyfinWebSocketService: JellyfinWebSocketService,
private readonly jellyfinStreamBuilder: JellyfinStreamBuilderService,
private readonly eventEmitter: EventEmitter2,
) {}
@OnEvent('playback.newTrack')
handleOnNewTrack(newTrack: Track) {
const resource = createAudioResource(newTrack.streamUrl);
@OnEvent('internal.audio.announce')
handleOnNewTrack(track: GenericTrack) {
const resource = createAudioResource(
track.getStreamUrl(this.jellyfinStreamBuilder),
);
this.playResource(resource);
}
@ -96,7 +104,6 @@ export class DiscordVoiceService {
/**
* Pauses the current audio player
*/
@OnEvent('playback.control.pause')
pause() {
this.createAndReturnOrGetAudioPlayer().pause();
this.eventEmitter.emit('playback.state.pause', true);
@ -105,7 +112,6 @@ export class DiscordVoiceService {
/**
* Stops the audio player
*/
@OnEvent('playback.control.stop')
stop(force: boolean): boolean {
const stopped = this.createAndReturnOrGetAudioPlayer().stop(force);
this.eventEmitter.emit('playback.state.stop');
@ -143,7 +149,6 @@ export class DiscordVoiceService {
* Checks if the current state is paused or not and toggles the states to the opposite.
* @returns The new paused state - true: paused, false: unpaused
*/
@OnEvent('playback.control.togglePause')
togglePaused(): boolean {
if (this.isPaused()) {
this.unpause();
@ -193,7 +198,7 @@ export class DiscordVoiceService {
private createAndReturnOrGetAudioPlayer() {
if (this.audioPlayer === undefined) {
this.logger.debug(
`Initialized new instance of Audio Player because it has not been defined yet`,
`Initialized new instance of AudioPlayer because it has not been defined yet`,
);
this.audioPlayer = createAudioPlayer();
this.attachEventListenersToAudioPlayer();
@ -220,7 +225,9 @@ export class DiscordVoiceService {
return;
}
const hasNextTrack = this.playbackService.hasNextTrack();
const hasNextTrack = this.playbackService
.getPlaylistOrDefault()
.hasNextTrackInPlaylist();
this.logger.debug(
`Deteced audio player status change from ${previousState.status} to ${
@ -229,11 +236,11 @@ export class DiscordVoiceService {
);
if (!hasNextTrack) {
this.logger.debug(`Audio Player has reached the end of the playlist`);
this.logger.debug(`Reached the end of the playlist`);
return;
}
this.playbackService.nextTrack();
this.playbackService.getPlaylistOrDefault().setNextTrackAsActiveTrack();
});
}
}

View File

@ -1,5 +1,3 @@
import { Injectable, Logger } from '@nestjs/common';
import { Api } from '@jellyfin/sdk';
import { PlaystateApi } from '@jellyfin/sdk/lib/generated-client/api/playstate-api';
import { SessionApi } from '@jellyfin/sdk/lib/generated-client/api/session-api';
@ -9,9 +7,12 @@ import {
} from '@jellyfin/sdk/lib/generated-client/models';
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api/playstate-api';
import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api';
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Track } from '../../types/track';
import { PlaybackService } from '../../playback/playback.service';
import { Track } from '../../types/track';
@Injectable()
export class JellyinPlaystateService {
@ -53,35 +54,4 @@ export class JellyinPlaystateService {
},
});
}
@OnEvent('playback.state.pause')
private async onPlaybackPaused(isPaused: boolean) {
const activeTrack = this.playbackService.getActiveTrack();
if (!activeTrack) {
return;
}
await this.playstateApi.reportPlaybackProgress({
playbackProgressInfo: {
ItemId: activeTrack.track.jellyfinId,
IsPaused: isPaused,
},
});
}
@OnEvent('playback.state.stop')
private async onPlaybackStopped() {
const activeTrack = this.playbackService.getActiveTrack();
if (!activeTrack) {
return;
}
await this.playstateApi.reportPlaybackStopped({
playbackStopInfo: {
ItemId: activeTrack.track.jellyfinId,
},
});
}
}

View File

@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { JellyfinService } from './jellyfin.service';
@Injectable()
@ -11,7 +12,7 @@ export class JellyfinStreamBuilderService {
const api = this.jellyfinService.getApi();
this.logger.debug(
`Attempting to build stream resource for item ${jellyfinItemId} with bitrate ${bitrate}`,
`Building stream for '${jellyfinItemId}' with bitrate ${bitrate}`,
);
const accessToken = this.jellyfinService.getApi().accessToken;

View File

@ -1,19 +1,25 @@
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { JellyfinService } from './jellyfin.service';
import {
PlaystateCommand,
SessionMessageType,
} from '@jellyfin/sdk/lib/generated-client/models';
import { WebSocket } from 'ws';
import { PlaybackService } from '../../playback/playback.service';
import { JellyfinSearchService } from './jellyfin.search.service';
import { JellyfinStreamBuilderService } from './jellyfin.stream.builder.service';
import { Track } from '../../types/track';
import { PlayNowCommand, SessionApiSendPlaystateCommandRequest } from '../../types/websocket';
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { WebSocket } from 'ws';
import { PlaybackService } from '../../playback/playback.service';
import {
PlayNowCommand,
SessionApiSendPlaystateCommandRequest,
} from '../../types/websocket';
import { GenericTrack } from '../../models/shared/GenericTrack';
import { JellyfinSearchService } from './jellyfin.search.service';
import { JellyfinService } from './jellyfin.service';
import { JellyfinStreamBuilderService } from './jellyfin.stream.builder.service';
@Injectable()
export class JellyfinWebSocketService implements OnModuleDestroy {
private webSocket: WebSocket;
@ -82,7 +88,7 @@ export class JellyfinWebSocketService implements OnModuleDestroy {
return this.webSocket.readyState;
}
protected messageHandler(data: any) {
protected async messageHandler(data: any) {
const msg: JellyMessage<unknown> = JSON.parse(data);
switch (msg.MessageType) {
@ -102,38 +108,22 @@ export class JellyfinWebSocketService implements OnModuleDestroy {
`Adding ${ids.length} ids to the queue using controls from the websocket`,
);
ids.forEach((id, index) => {
this.jellyfinSearchService
.getById(id)
.then((response) => {
const track: Track = {
name: response.Name,
durationInMilliseconds: response.RunTimeTicks / 10000,
jellyfinId: response.Id,
streamUrl: this.jellyfinStreamBuilderService.buildStreamUrl(
response.Id,
96000,
),
remoteImages: {
Images: [],
Providers: [],
TotalRecordCount: 0,
},
};
const trackId = this.playbackService.enqueueTrack(track);
if (index !== 0) {
return;
}
this.playbackService.setActiveTrack(trackId);
this.playbackService.getActiveTrackAndEmitEvent();
})
.catch((err) => {
this.logger.error(err);
});
const tracks = ids.map(async (id) => {
try {
const hint = await this.jellyfinSearchService.getById(id);
return {
id: id,
name: hint.Name,
duration: hint.RunTimeTicks / 10000,
remoteImages: {},
} as GenericTrack;
} catch (err) {
this.logger.error('TODO');
}
});
const resolvedTracks = await Promise.all(tracks);
const playlist = this.playbackService.getPlaylistOrDefault();
playlist.enqueueTracks(resolvedTracks);
break;
case SessionMessageType[SessionMessageType.Playstate]:
const sendPlaystateCommandRequest =

View File

@ -1,4 +1,4 @@
import { Command, DiscordCommand } from '@discord-nestjs/core';
import { Command, Handler, IA } from '@discord-nestjs/core';
import { Injectable } from '@nestjs/common/decorators';
@ -7,18 +7,19 @@ import { CommandInteraction } from 'discord.js';
import { DiscordMessageService } from '../clients/discord/discord.message.service';
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
@Injectable()
@Command({
name: 'disconnect',
description: 'Join your current voice channel',
})
@Injectable()
export class DisconnectCommand implements DiscordCommand {
export class DisconnectCommand {
constructor(
private readonly discordVoiceService: DiscordVoiceService,
private readonly discordMessageService: DiscordMessageService,
) {}
async handler(interaction: CommandInteraction): Promise<void> {
@Handler()
async handler(@IA() interaction: CommandInteraction): Promise<void> {
await interaction.reply({
embeds: [
this.discordMessageService.buildMessage({

View File

@ -1,4 +1,4 @@
import { Command, DiscordCommand } from '@discord-nestjs/core';
import { Command, Handler, IA } from '@discord-nestjs/core';
import { Injectable } from '@nestjs/common';
@ -6,15 +6,16 @@ import { CommandInteraction } from 'discord.js';
import { DiscordMessageService } from '../clients/discord/discord.message.service';
@Injectable()
@Command({
name: 'help',
description: 'Get help if you&apos;re having problems with this bot',
})
@Injectable()
export class HelpCommand implements DiscordCommand {
export class HelpCommand {
constructor(private readonly discordMessageService: DiscordMessageService) {}
async handler(interaction: CommandInteraction): Promise<void> {
@Handler()
async handler(@IA() interaction: CommandInteraction): Promise<void> {
await interaction.reply({
embeds: [
this.discordMessageService.buildMessage({

View File

@ -1,4 +1,4 @@
import { Command, DiscordCommand } from '@discord-nestjs/core';
import { Command, Handler, IA } from '@discord-nestjs/core';
import { Injectable } from '@nestjs/common';
@ -12,14 +12,15 @@ import { DiscordMessageService } from '../clients/discord/discord.message.servic
description: 'Go to the next track in the playlist',
})
@Injectable()
export class SkipTrackCommand implements DiscordCommand {
export class SkipTrackCommand {
constructor(
private readonly playbackService: PlaybackService,
private readonly discordMessageService: DiscordMessageService,
) {}
async handler(interaction: CommandInteraction): Promise<void> {
if (!this.playbackService.nextTrack()) {
@Handler()
async handler(@IA() interaction: CommandInteraction): Promise<void> {
if (!this.playbackService.getPlaylistOrDefault().hasActiveTrack()) {
await interaction.reply({
embeds: [
this.discordMessageService.buildErrorMessage({
@ -27,8 +28,10 @@ export class SkipTrackCommand implements DiscordCommand {
}),
],
});
return;
}
this.playbackService.getPlaylistOrDefault().setNextTrackAsActiveTrack();
await interaction.reply({
embeds: [
this.discordMessageService.buildMessage({

View File

@ -1,4 +1,4 @@
import { Command, DiscordCommand } from '@discord-nestjs/core';
import { Command, Handler, IA } from '@discord-nestjs/core';
import { Injectable } from '@nestjs/common';
@ -7,18 +7,19 @@ import { CommandInteraction } from 'discord.js';
import { DiscordMessageService } from '../clients/discord/discord.message.service';
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
@Injectable()
@Command({
name: 'pause',
description: 'Pause or resume the playback of the current track',
})
@Injectable()
export class PausePlaybackCommand implements DiscordCommand {
export class PausePlaybackCommand {
constructor(
private readonly discordVoiceService: DiscordVoiceService,
private readonly discordMessageService: DiscordMessageService,
) {}
async handler(interaction: CommandInteraction): Promise<void> {
@Handler()
async handler(@IA() interaction: CommandInteraction): Promise<void> {
const shouldBePaused = this.discordVoiceService.togglePaused();
await interaction.reply({

View File

@ -1,17 +1,19 @@
import { SlashCommandPipe } from '@discord-nestjs/common';
import {
Command,
DiscordTransformedCommand,
Handler,
IA,
InteractionEvent,
On,
Payload,
TransformedCommandExecutionContext,
} from '@discord-nestjs/core';
import { RemoteImageResult } from '@jellyfin/sdk/lib/generated-client/models';
import { Logger } from '@nestjs/common/services';
import { Injectable } from '@nestjs/common';
import { Logger } from '@nestjs/common/services';
import {
CommandInteraction,
ComponentType,
Events,
GuildMember,
@ -29,17 +31,16 @@ import { DiscordMessageService } from '../clients/discord/discord.message.servic
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
import { JellyfinSearchService } from '../clients/jellyfin/jellyfin.search.service';
import { JellyfinStreamBuilderService } from '../clients/jellyfin/jellyfin.stream.builder.service';
import { GenericTrack } from '../models/shared/GenericTrack';
import { chooseSuitableRemoteImage } from '../utils/remoteImages/remoteImages';
import { trimStringToFixedLength } from '../utils/stringUtils/stringUtils';
@Injectable()
@Command({
name: 'play',
description: 'Search for an item on your Jellyfin instance',
})
@Injectable()
export class PlayItemCommand
implements DiscordTransformedCommand<TrackRequestDto>
{
export class PlayItemCommand {
private readonly logger: Logger = new Logger(PlayItemCommand.name);
constructor(
@ -50,11 +51,12 @@ export class PlayItemCommand
private readonly jellyfinStreamBuilder: JellyfinStreamBuilderService,
) {}
@Handler()
async handler(
@Payload() dto: TrackRequestDto,
executionContext: TransformedCommandExecutionContext<any>,
@InteractionEvent(SlashCommandPipe) dto: TrackRequestDto,
@IA() interaction: CommandInteraction,
): Promise<InteractionReplyOptions | string> {
await executionContext.interaction.deferReply();
await interaction.deferReply();
const items = await this.jellyfinSearchService.search(dto.search);
const parsedItems = await Promise.all(
@ -69,7 +71,7 @@ export class PlayItemCommand
);
if (parsedItems.length === 0) {
await executionContext.interaction.followUp({
await interaction.followUp({
embeds: [
this.discordMessageService.buildErrorMessage({
title: 'No results for your search query found',
@ -109,7 +111,7 @@ export class PlayItemCommand
emoji: item.getEmoji(),
}));
await executionContext.interaction.followUp({
await interaction.followUp({
embeds: [
this.discordMessageService.buildMessage({
title: 'Jellyfin Search Results',
@ -184,8 +186,6 @@ export class PlayItemCommand
this.logger.debug('Successfully joined the voice channel');
const bitrate = guildMember.voice.channel.bitrate;
const valueParts = interaction.values[0].split('_');
const type = valueParts[0];
const id = valueParts[1];
@ -206,7 +206,6 @@ export class PlayItemCommand
);
const addedIndex = this.enqueueSingleTrack(
item as BaseJellyfinAudioPlayable,
bitrate,
remoteImagesOfCurrentAlbum,
);
await interaction.editReply({
@ -234,7 +233,6 @@ export class PlayItemCommand
album.SearchHints.forEach((item) => {
this.enqueueSingleTrack(
item as BaseJellyfinAudioPlayable,
bitrate,
remoteImages,
);
});
@ -267,7 +265,6 @@ export class PlayItemCommand
addedRemoteImages.Images.concat(remoteImages.Images);
this.enqueueSingleTrack(
item as BaseJellyfinAudioPlayable,
bitrate,
remoteImages,
);
}
@ -310,22 +307,15 @@ export class PlayItemCommand
private enqueueSingleTrack(
jellyfinPlayable: BaseJellyfinAudioPlayable,
bitrate: number,
remoteImageResult: RemoteImageResult,
) {
const stream = this.jellyfinStreamBuilder.buildStreamUrl(
jellyfinPlayable.Id,
bitrate,
);
const milliseconds = jellyfinPlayable.RunTimeTicks / 10000;
return this.playbackService.enqueueTrack({
jellyfinId: jellyfinPlayable.Id,
name: jellyfinPlayable.Name,
durationInMilliseconds: milliseconds,
streamUrl: stream,
remoteImages: remoteImageResult,
});
return this.playbackService
.getPlaylistOrDefault()
.enqueueTracks([
GenericTrack.constructFromJellyfinPlayable(
jellyfinPlayable,
remoteImageResult,
),
]);
}
}

View File

@ -1,4 +1,4 @@
import { Command, DiscordCommand } from '@discord-nestjs/core';
import { Command, Handler, IA } from '@discord-nestjs/core';
import { Injectable } from '@nestjs/common';
@ -8,24 +8,24 @@ import { PlaybackService } from '../playback/playback.service';
import { Constants } from '../utils/constants';
import { formatMillisecondsAsHumanReadable } from '../utils/timeUtils';
import { DiscordMessageService } from '../clients/discord/discord.message.service';
import { chooseSuitableRemoteImageFromTrack } from '../utils/remoteImages/remoteImages';
import { trimStringToFixedLength } from '../utils/stringUtils/stringUtils';
@Injectable()
@Command({
name: 'playlist',
description: 'Print the current track information',
})
@Injectable()
export class PlaylistCommand implements DiscordCommand {
export class PlaylistCommand {
constructor(
private readonly discordMessageService: DiscordMessageService,
private readonly playbackService: PlaybackService,
) {}
async handler(interaction: CommandInteraction): Promise<void> {
const playList = this.playbackService.getPlaylist();
@Handler()
async handler(@IA() interaction: CommandInteraction): Promise<void> {
const playlist = this.playbackService.getPlaylistOrDefault();
if (playList.tracks.length === 0) {
if (!playlist || playlist.tracks.length === 0) {
await interaction.reply({
embeds: [
this.discordMessageService.buildMessage({
@ -35,15 +35,16 @@ export class PlaylistCommand implements DiscordCommand {
}),
],
});
return;
}
const tracklist = playList.tracks
const tracklist = playlist.tracks
.slice(0, 10)
.map((track, index) => {
const isCurrent = track.id === playList.activeTrack;
const isCurrent = track === playlist.getActiveTrack();
let point = this.getListPoint(isCurrent, index);
point += `**${trimStringToFixedLength(track.track.name, 30)}**`;
point += `**${trimStringToFixedLength(track.name, 30)}**`;
if (isCurrent) {
point += ' :loud_sound:';
@ -52,16 +53,13 @@ export class PlaylistCommand implements DiscordCommand {
point += '\n';
point += Constants.Design.InvisibleSpace.repeat(2);
point += 'Duration: ';
point += formatMillisecondsAsHumanReadable(
track.track.durationInMilliseconds,
);
point += formatMillisecondsAsHumanReadable(track.getDuration());
return point;
})
.join('\n');
const activeTrack = this.playbackService.getActiveTrack();
const remoteImage = chooseSuitableRemoteImageFromTrack(activeTrack.track);
// const remoteImage = chooseSuitableRemoteImageFromTrack(playlist.getActiveTrack());
const remoteImage = undefined;
await interaction.reply({
embeds: [

View File

@ -1,4 +1,4 @@
import { Command, DiscordCommand } from '@discord-nestjs/core';
import { Command, Handler, IA } from '@discord-nestjs/core';
import { Injectable } from '@nestjs/common/decorators';
@ -7,19 +7,20 @@ import { CommandInteraction } from 'discord.js';
import { PlaybackService } from '../playback/playback.service';
import { DiscordMessageService } from '../clients/discord/discord.message.service';
@Injectable()
@Command({
name: 'previous',
description: 'Go to the previous track',
})
@Injectable()
export class PreviousTrackCommand implements DiscordCommand {
export class PreviousTrackCommand {
constructor(
private readonly playbackService: PlaybackService,
private readonly discordMessageService: DiscordMessageService,
) {}
async handler(interaction: CommandInteraction): Promise<void> {
if (!this.playbackService.previousTrack()) {
@Handler()
async handler(@IA() interaction: CommandInteraction): Promise<void> {
if (!this.playbackService.getPlaylistOrDefault().hasActiveTrack()) {
await interaction.reply({
embeds: [
this.discordMessageService.buildErrorMessage({
@ -27,8 +28,10 @@ export class PreviousTrackCommand implements DiscordCommand {
}),
],
});
return;
}
this.playbackService.getPlaylistOrDefault().setPreviousTrackAsActiveTrack();
await interaction.reply({
embeds: [
this.discordMessageService.buildMessage({

View File

@ -1,8 +1,4 @@
import {
Command,
DiscordCommand,
InjectDiscordClient,
} from '@discord-nestjs/core';
import { Command, Handler, IA, InjectDiscordClient } from '@discord-nestjs/core';
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
@ -21,7 +17,7 @@ import { JellyfinService } from '../clients/jellyfin/jellyfin.service';
description: 'Display the current status for troubleshooting',
})
@Injectable()
export class StatusCommand implements DiscordCommand {
export class StatusCommand {
constructor(
@InjectDiscordClient()
private readonly client: Client,
@ -29,7 +25,8 @@ export class StatusCommand implements DiscordCommand {
private readonly jellyfinService: JellyfinService,
) {}
async handler(interaction: CommandInteraction): Promise<void> {
@Handler()
async handler(@IA() interaction: CommandInteraction): Promise<void> {
await interaction.reply({
embeds: [
this.discordMessageService.buildMessage({

View File

@ -1,4 +1,4 @@
import { Command, DiscordCommand } from '@discord-nestjs/core';
import { Command, Handler, IA } from '@discord-nestjs/core';
import { Injectable } from '@nestjs/common';
@ -13,15 +13,16 @@ import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
description: 'Stop playback entirely and clear the current playlist',
})
@Injectable()
export class StopPlaybackCommand implements DiscordCommand {
export class StopPlaybackCommand {
constructor(
private readonly playbackService: PlaybackService,
private readonly discordMessageService: DiscordMessageService,
private readonly discordVoiceService: DiscordVoiceService,
) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async handler(interaction: CommandInteraction): Promise<void> {
const hasActiveTrack = this.playbackService.hasActiveTrack();
@Handler()
async handler(@IA() interaction: CommandInteraction): Promise<void> {
const hasActiveTrack = this.playbackService.getPlaylistOrDefault();
const title = hasActiveTrack
? 'Playback stopped successfully'
: 'Playback failed to stop';
@ -29,7 +30,7 @@ export class StopPlaybackCommand implements DiscordCommand {
? 'In addition, your playlist has been cleared'
: 'There is no active track in the queue';
if (hasActiveTrack) {
this.playbackService.clear();
this.playbackService.getPlaylistOrDefault().clear();
this.discordVoiceService.stop(false);
}

View File

@ -1,4 +1,4 @@
import { Command, DiscordCommand } from '@discord-nestjs/core';
import { Command, Handler, IA } from '@discord-nestjs/core';
import { Injectable, Logger } from '@nestjs/common';
@ -7,12 +7,12 @@ import { CommandInteraction, GuildMember } from 'discord.js';
import { DiscordMessageService } from '../clients/discord/discord.message.service';
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
@Injectable()
@Command({
name: 'summon',
description: 'Join your current voice channel',
})
@Injectable()
export class SummonCommand implements DiscordCommand {
export class SummonCommand {
private readonly logger = new Logger(SummonCommand.name);
constructor(
@ -20,7 +20,8 @@ export class SummonCommand implements DiscordCommand {
private readonly discordMessageService: DiscordMessageService,
) {}
async handler(interaction: CommandInteraction): Promise<void> {
@Handler()
async handler(@IA() interaction: CommandInteraction): Promise<void> {
await interaction.deferReply();
const guildMember = interaction.member as GuildMember;

View File

@ -1,29 +1,18 @@
import {
Catch,
DiscordArgumentMetadata,
DiscordExceptionFilter,
} from '@discord-nestjs/core';
import { Logger } from '@nestjs/common';
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
CommandInteraction,
} from 'discord.js';
import { DiscordMessageService } from '../clients/discord/discord.message.service';
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from '@nestjs/common';
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, CommandInteraction } from 'discord.js';
import { Constants } from '../utils/constants';
import { DiscordMessageService } from '../clients/discord/discord.message.service';
@Catch(Error)
export class CommandExecutionError implements DiscordExceptionFilter {
export class CommandExecutionError implements ExceptionFilter {
private readonly logger = new Logger(CommandExecutionError.name);
constructor(private readonly discordMessageService: DiscordMessageService) {}
async catch(
exception: Error,
metadata: DiscordArgumentMetadata<string, any>,
): Promise<void> {
const interaction: CommandInteraction = metadata.eventArgs[0];
async catch(exception: Error, host: ArgumentsHost): Promise<void> {
const interaction = host.getArgByIndex(0) as CommandInteraction;
if (!interaction.isCommand()) {
return;
@ -34,6 +23,10 @@ export class CommandExecutionError implements DiscordExceptionFilter {
exception.stack,
);
if (!interaction.isRepliable()) {
return;
}
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setLabel('Report this issue')

View File

@ -0,0 +1,129 @@
import { EventEmitter2 } from '@nestjs/event-emitter';
import { GenericTrack } from './GenericTrack';
export class GenericPlaylist {
tracks: GenericTrack[];
activeTrackIndex?: number;
constructor(private readonly eventEmitter: EventEmitter2) {
this.tracks = [];
}
/**
* Returns if the playlist has been started.
* Does not indicate if it's paused.
* @returns if the playlist has been started and has an active track
*/
hasStarted() {
return this.activeTrackIndex !== undefined;
}
/**
* Checks if the active track is out of bounds
* @returns active track or undefined if there's none
*/
getActiveTrack(): GenericTrack | undefined {
if (this.isActiveTrackOutOfSync()) {
return undefined;
}
return this.tracks[this.activeTrackIndex];
}
hasActiveTrack(): boolean {
return (
this.activeTrackIndex !== undefined && !this.isActiveTrackOutOfSync()
);
}
/**
* Go to the next track in the playlist
* @returns if the track has been changed successfully
*/
setNextTrackAsActiveTrack(): boolean {
if (this.activeTrackIndex >= this.tracks.length) {
return false;
}
this.activeTrackIndex++;
this.eventEmitter.emit('controls.playlist.tracks.next', {
newActive: this.activeTrackIndex,
});
this.announceTrackChange();
return true;
}
/**
* Go to the previous track in the playlist
* @returns if the track has been changed successfully
*/
setPreviousTrackAsActiveTrack(): boolean {
if (this.activeTrackIndex <= 0) {
return false;
}
this.activeTrackIndex--;
this.eventEmitter.emit('controls.playlist.tracks.previous', {
newActive: this.activeTrackIndex,
});
this.announceTrackChange();
return true;
}
/**
* Add new track(-s) to the playlist
* @param tracks the tracks that should be added
* @returns the new lendth of the tracks in the playlist
*/
enqueueTracks(tracks: GenericTrack[]) {
this.eventEmitter.emit('controls.playlist.tracks.enqueued', {
count: tracks.length,
activeTrack: this.activeTrackIndex,
});
const length = this.tracks.push(...tracks);
this.announceTrackChange();
return length;
}
/**
* Check if there is a next track
* @returns if there is a track next in the playlist
*/
hasNextTrackInPlaylist() {
return this.activeTrackIndex < this.tracks.length;
}
/**
* Check if there is a previous track
* @returns if there is a previous track in the playlist
*/
hasPreviousTrackInPlaylist() {
return this.activeTrackIndex > 0;
}
clear() {
this.eventEmitter.emit('controls.playlist.tracks.clear');
this.tracks = [];
this.activeTrackIndex = undefined;
}
private announceTrackChange() {
if (!this.activeTrackIndex) {
this.activeTrackIndex = 0;
}
this.eventEmitter.emit('internal.audio.announce', this.getActiveTrack());
}
private isActiveTrackOutOfSync(): boolean {
return (
this.activeTrackIndex < 0 || this.activeTrackIndex >= this.tracks.length
);
}
}
export type PlaylistPlaybackType =
| 'once'
| 'repeat-once'
| 'repeat-indefinetly'
| 'shuffle';

View File

@ -0,0 +1,59 @@
import { RemoteImageResult } from '@jellyfin/sdk/lib/generated-client/models';
import { BaseJellyfinAudioPlayable } from '../jellyfinAudioItems';
import { JellyfinStreamBuilderService } from '../../clients/jellyfin/jellyfin.stream.builder.service';
export class GenericTrack {
/**
* The identifier of this track, structured as a UID.
* This id can be used to build a stream url and send more API requests to Jellyfin
*/
readonly id: string;
/**
* The name of the track
*/
readonly name: string;
/**
* The duration of the track
*/
readonly duration: number;
/**
* A result object that contains a collection of images that are available outside the current network.
*/
readonly remoteImages?: RemoteImageResult;
constructor(
id: string,
name: string,
duration: number,
remoteImages?: RemoteImageResult,
) {
this.id = id;
this.name = name;
this.duration = duration;
this.remoteImages = remoteImages;
}
getDuration() {
return this.duration;
}
getStreamUrl(streamBuilder: JellyfinStreamBuilderService) {
return streamBuilder.buildStreamUrl(this.id, 96000);
}
static constructFromJellyfinPlayable(
playable: BaseJellyfinAudioPlayable,
remoteImages: RemoteImageResult | undefined,
): GenericTrack {
return new GenericTrack(
playable.Id,
playable.Name,
playable.RunTimeTicks / 1000,
remoteImages,
);
}
}

View File

@ -1,143 +1,21 @@
import { Injectable, Logger } from '@nestjs/common';
import { Playlist } from '../types/playlist';
import { Track } from '../types/track';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import { v4 as uuidv4 } from 'uuid';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { GenericPlaylist } from '../models/shared/GenericPlaylist';
@Injectable()
export class PlaybackService {
private readonly logger = new Logger(PlaybackService.name);
private readonly playlist: Playlist = {
tracks: [],
activeTrack: null,
};
private playlist: GenericPlaylist | undefined = undefined;
constructor(private readonly eventEmitter: EventEmitter2) {}
getActiveTrack() {
return this.getTrackById(this.playlist.activeTrack);
}
setActiveTrack(trackId: string) {
const track = this.getTrackById(trackId);
if (!track) {
throw Error('track is not in playlist');
getPlaylistOrDefault(): GenericPlaylist {
if (this.playlist) {
return this.playlist;
}
this.playlist.activeTrack = track.id;
}
nextTrack() {
const keys = this.getTrackIds();
const index = this.getActiveIndex();
if (!this.hasActiveTrack() || index + 1 >= keys.length) {
this.logger.debug(
`Unable to go to next track, because playback has reached end of the playlist`,
);
return false;
}
const newKey = keys[index + 1];
this.setActiveTrack(newKey);
this.getActiveTrackAndEmitEvent();
return true;
}
previousTrack() {
const index = this.getActiveIndex();
if (!this.hasActiveTrack() || index < 1) {
this.logger.debug(
`Unable to go to previous track, because there is no previous track in the playlist`,
);
return false;
}
const keys = this.getTrackIds();
const newKey = keys[index - 1];
this.setActiveTrack(newKey);
this.getActiveTrackAndEmitEvent();
return true;
}
enqueueTrack(track: Track) {
const uuid = uuidv4();
const emptyBefore = this.playlist.tracks.length === 0;
this.playlist.tracks.push({
id: uuid,
track: track,
});
this.logger.debug(
`Added the track '${track.jellyfinId}' to the current playlist`,
);
if (emptyBefore) {
this.setActiveTrack(this.playlist.tracks.find((x) => x.id === uuid).id);
this.getActiveTrackAndEmitEvent();
}
return uuid;
}
enqueTrackAndInstantyPlay(track: Track) {
const uuid = uuidv4();
this.playlist.tracks.push({
id: uuid,
track: track,
});
this.setActiveTrack(uuid);
this.getActiveTrackAndEmitEvent();
}
set(tracks: Track[]) {
this.playlist.tracks = tracks.map((t) => ({
id: uuidv4(),
track: t,
}));
}
clear() {
this.playlist.tracks = [];
}
hasNextTrack() {
return this.getActiveIndex() + 1 < this.getTrackIds().length;
}
hasActiveTrack() {
return this.playlist.activeTrack !== null;
}
getPlaylist(): Playlist {
this.playlist = new GenericPlaylist(this.eventEmitter);
return this.playlist;
}
private getTrackById(id: string) {
return this.playlist.tracks.find((x) => x.id === id);
}
private getTrackIds() {
return this.playlist.tracks.map((item) => item.id);
}
private getActiveIndex() {
return this.getTrackIds().indexOf(this.playlist.activeTrack);
}
getActiveTrackAndEmitEvent() {
const activeTrack = this.getActiveTrack();
this.logger.debug(
`A new track (${activeTrack.id}) was requested and will be emmitted as an event`,
);
this.eventEmitter.emit('playback.newTrack', activeTrack.track);
}
}

View File

@ -368,10 +368,10 @@
class-transformer "0.5.1"
class-validator "0.14.0"
"@discord-nestjs/core@^4.3.1":
version "4.3.1"
resolved "https://registry.yarnpkg.com/@discord-nestjs/core/-/core-4.3.1.tgz#b0a71834147d2bfac8efe37b7091667ce7f146d6"
integrity sha512-38Bk7V0W+LF2qUbE6K+CfqRR309jU/tbj6ZZLN97nFHcYCcsteKQ7HxJeM15pw09Vvb7xbB+3DIls/YHpq3lRA==
"@discord-nestjs/core@^5.3.0":
version "5.3.0"
resolved "https://registry.yarnpkg.com/@discord-nestjs/core/-/core-5.3.0.tgz#8e93b0310e8cc2c0cde74c6317d949d6e9d28d2d"
integrity sha512-eHVzuPCu3EbQyTln+ZEH0/Jwe0xPG7Z1eZV655jZoCSpq7RmvxWcTG/REf3XMSgtYFN27HaXa9vPCsgUOnp9xQ==
dependencies:
class-transformer "0.5.1"