mirror of
https://github.com/informaticker/discord-jellyfin-bot.git
synced 2024-11-25 02:51:57 +01:00
♻️ Playlist and playback manager (#89)
This commit is contained in:
parent
6c75861c49
commit
3adec06df7
@ -22,7 +22,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discord-nestjs/common": "^5.1.5",
|
"@discord-nestjs/common": "^5.1.5",
|
||||||
"@discord-nestjs/core": "^4.3.1",
|
"@discord-nestjs/core": "^5.3.0",
|
||||||
"@discordjs/opus": "^0.9.0",
|
"@discordjs/opus": "^0.9.0",
|
||||||
"@discordjs/voice": "^0.14.0",
|
"@discordjs/voice": "^0.14.0",
|
||||||
"@jellyfin/sdk": "^0.7.0",
|
"@jellyfin/sdk": "^0.7.0",
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { registerFilterGlobally } from '@discord-nestjs/core';
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { OnModuleDestroy } from '@nestjs/common/interfaces/hooks';
|
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 { JellyfinClientModule } from '../jellyfin/jellyfin.module';
|
||||||
|
import { PlaybackModule } from '../../playback/playback.module';
|
||||||
|
|
||||||
import { DiscordConfigService } from './discord.config.service';
|
import { DiscordConfigService } from './discord.config.service';
|
||||||
import { DiscordMessageService } from './discord.message.service';
|
import { DiscordMessageService } from './discord.message.service';
|
||||||
import { DiscordVoiceService } from './discord.voice.service';
|
import { DiscordVoiceService } from './discord.voice.service';
|
||||||
@ -11,15 +11,7 @@ import { DiscordVoiceService } from './discord.voice.service';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [PlaybackModule, JellyfinClientModule],
|
imports: [PlaybackModule, JellyfinClientModule],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [DiscordConfigService, DiscordVoiceService, DiscordMessageService],
|
||||||
DiscordConfigService,
|
|
||||||
DiscordVoiceService,
|
|
||||||
DiscordMessageService,
|
|
||||||
{
|
|
||||||
provide: registerFilterGlobally(),
|
|
||||||
useClass: CommandExecutionError,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
exports: [DiscordConfigService, DiscordVoiceService, DiscordMessageService],
|
exports: [DiscordConfigService, DiscordVoiceService, DiscordMessageService],
|
||||||
})
|
})
|
||||||
export class DiscordClientModule implements OnModuleDestroy {
|
export class DiscordClientModule implements OnModuleDestroy {
|
||||||
|
@ -9,14 +9,19 @@ import {
|
|||||||
joinVoiceChannel,
|
joinVoiceChannel,
|
||||||
VoiceConnection,
|
VoiceConnection,
|
||||||
} from '@discordjs/voice';
|
} 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 { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||||
|
|
||||||
import { GuildMember } from 'discord.js';
|
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 { GenericTryHandler } from '../../models/generic-try-handler';
|
||||||
import { PlaybackService } from '../../playback/playback.service';
|
import { PlaybackService } from '../../playback/playback.service';
|
||||||
import { Track } from '../../types/track';
|
import { GenericTrack } from '../../models/shared/GenericTrack';
|
||||||
import { JellyfinWebSocketService } from '../jellyfin/jellyfin.websocket.service';
|
|
||||||
import { DiscordMessageService } from './discord.message.service';
|
import { DiscordMessageService } from './discord.message.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -29,12 +34,15 @@ export class DiscordVoiceService {
|
|||||||
private readonly discordMessageService: DiscordMessageService,
|
private readonly discordMessageService: DiscordMessageService,
|
||||||
private readonly playbackService: PlaybackService,
|
private readonly playbackService: PlaybackService,
|
||||||
private readonly jellyfinWebSocketService: JellyfinWebSocketService,
|
private readonly jellyfinWebSocketService: JellyfinWebSocketService,
|
||||||
|
private readonly jellyfinStreamBuilder: JellyfinStreamBuilderService,
|
||||||
private readonly eventEmitter: EventEmitter2,
|
private readonly eventEmitter: EventEmitter2,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@OnEvent('playback.newTrack')
|
@OnEvent('internal.audio.announce')
|
||||||
handleOnNewTrack(newTrack: Track) {
|
handleOnNewTrack(track: GenericTrack) {
|
||||||
const resource = createAudioResource(newTrack.streamUrl);
|
const resource = createAudioResource(
|
||||||
|
track.getStreamUrl(this.jellyfinStreamBuilder),
|
||||||
|
);
|
||||||
this.playResource(resource);
|
this.playResource(resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,7 +104,6 @@ export class DiscordVoiceService {
|
|||||||
/**
|
/**
|
||||||
* Pauses the current audio player
|
* Pauses the current audio player
|
||||||
*/
|
*/
|
||||||
@OnEvent('playback.control.pause')
|
|
||||||
pause() {
|
pause() {
|
||||||
this.createAndReturnOrGetAudioPlayer().pause();
|
this.createAndReturnOrGetAudioPlayer().pause();
|
||||||
this.eventEmitter.emit('playback.state.pause', true);
|
this.eventEmitter.emit('playback.state.pause', true);
|
||||||
@ -105,7 +112,6 @@ export class DiscordVoiceService {
|
|||||||
/**
|
/**
|
||||||
* Stops the audio player
|
* Stops the audio player
|
||||||
*/
|
*/
|
||||||
@OnEvent('playback.control.stop')
|
|
||||||
stop(force: boolean): boolean {
|
stop(force: boolean): boolean {
|
||||||
const stopped = this.createAndReturnOrGetAudioPlayer().stop(force);
|
const stopped = this.createAndReturnOrGetAudioPlayer().stop(force);
|
||||||
this.eventEmitter.emit('playback.state.stop');
|
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.
|
* 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
|
* @returns The new paused state - true: paused, false: unpaused
|
||||||
*/
|
*/
|
||||||
@OnEvent('playback.control.togglePause')
|
|
||||||
togglePaused(): boolean {
|
togglePaused(): boolean {
|
||||||
if (this.isPaused()) {
|
if (this.isPaused()) {
|
||||||
this.unpause();
|
this.unpause();
|
||||||
@ -193,7 +198,7 @@ export class DiscordVoiceService {
|
|||||||
private createAndReturnOrGetAudioPlayer() {
|
private createAndReturnOrGetAudioPlayer() {
|
||||||
if (this.audioPlayer === undefined) {
|
if (this.audioPlayer === undefined) {
|
||||||
this.logger.debug(
|
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.audioPlayer = createAudioPlayer();
|
||||||
this.attachEventListenersToAudioPlayer();
|
this.attachEventListenersToAudioPlayer();
|
||||||
@ -220,7 +225,9 @@ export class DiscordVoiceService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasNextTrack = this.playbackService.hasNextTrack();
|
const hasNextTrack = this.playbackService
|
||||||
|
.getPlaylistOrDefault()
|
||||||
|
.hasNextTrackInPlaylist();
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Deteced audio player status change from ${previousState.status} to ${
|
`Deteced audio player status change from ${previousState.status} to ${
|
||||||
@ -229,11 +236,11 @@ export class DiscordVoiceService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!hasNextTrack) {
|
if (!hasNextTrack) {
|
||||||
this.logger.debug(`Audio Player has reached the end of the playlist`);
|
this.logger.debug(`Reached the end of the playlist`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.playbackService.nextTrack();
|
this.playbackService.getPlaylistOrDefault().setNextTrackAsActiveTrack();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { Api } from '@jellyfin/sdk';
|
import { Api } from '@jellyfin/sdk';
|
||||||
import { PlaystateApi } from '@jellyfin/sdk/lib/generated-client/api/playstate-api';
|
import { PlaystateApi } from '@jellyfin/sdk/lib/generated-client/api/playstate-api';
|
||||||
import { SessionApi } from '@jellyfin/sdk/lib/generated-client/api/session-api';
|
import { SessionApi } from '@jellyfin/sdk/lib/generated-client/api/session-api';
|
||||||
@ -9,9 +7,12 @@ import {
|
|||||||
} from '@jellyfin/sdk/lib/generated-client/models';
|
} from '@jellyfin/sdk/lib/generated-client/models';
|
||||||
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api/playstate-api';
|
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api/playstate-api';
|
||||||
import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api';
|
import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api';
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import { Track } from '../../types/track';
|
|
||||||
import { PlaybackService } from '../../playback/playback.service';
|
import { PlaybackService } from '../../playback/playback.service';
|
||||||
|
import { Track } from '../../types/track';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JellyinPlaystateService {
|
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
import { JellyfinService } from './jellyfin.service';
|
import { JellyfinService } from './jellyfin.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -11,7 +12,7 @@ export class JellyfinStreamBuilderService {
|
|||||||
const api = this.jellyfinService.getApi();
|
const api = this.jellyfinService.getApi();
|
||||||
|
|
||||||
this.logger.debug(
|
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;
|
const accessToken = this.jellyfinService.getApi().accessToken;
|
||||||
|
@ -1,19 +1,25 @@
|
|||||||
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
|
||||||
import { Cron } from '@nestjs/schedule';
|
|
||||||
import { JellyfinService } from './jellyfin.service';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
PlaystateCommand,
|
PlaystateCommand,
|
||||||
SessionMessageType,
|
SessionMessageType,
|
||||||
} from '@jellyfin/sdk/lib/generated-client/models';
|
} from '@jellyfin/sdk/lib/generated-client/models';
|
||||||
import { WebSocket } from 'ws';
|
|
||||||
import { PlaybackService } from '../../playback/playback.service';
|
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
||||||
import { JellyfinSearchService } from './jellyfin.search.service';
|
import { Cron } from '@nestjs/schedule';
|
||||||
import { JellyfinStreamBuilderService } from './jellyfin.stream.builder.service';
|
|
||||||
import { Track } from '../../types/track';
|
|
||||||
import { PlayNowCommand, SessionApiSendPlaystateCommandRequest } from '../../types/websocket';
|
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
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()
|
@Injectable()
|
||||||
export class JellyfinWebSocketService implements OnModuleDestroy {
|
export class JellyfinWebSocketService implements OnModuleDestroy {
|
||||||
private webSocket: WebSocket;
|
private webSocket: WebSocket;
|
||||||
@ -82,7 +88,7 @@ export class JellyfinWebSocketService implements OnModuleDestroy {
|
|||||||
return this.webSocket.readyState;
|
return this.webSocket.readyState;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected messageHandler(data: any) {
|
protected async messageHandler(data: any) {
|
||||||
const msg: JellyMessage<unknown> = JSON.parse(data);
|
const msg: JellyMessage<unknown> = JSON.parse(data);
|
||||||
|
|
||||||
switch (msg.MessageType) {
|
switch (msg.MessageType) {
|
||||||
@ -102,38 +108,22 @@ export class JellyfinWebSocketService implements OnModuleDestroy {
|
|||||||
`Adding ${ids.length} ids to the queue using controls from the websocket`,
|
`Adding ${ids.length} ids to the queue using controls from the websocket`,
|
||||||
);
|
);
|
||||||
|
|
||||||
ids.forEach((id, index) => {
|
const tracks = ids.map(async (id) => {
|
||||||
this.jellyfinSearchService
|
try {
|
||||||
.getById(id)
|
const hint = await this.jellyfinSearchService.getById(id);
|
||||||
.then((response) => {
|
return {
|
||||||
const track: Track = {
|
id: id,
|
||||||
name: response.Name,
|
name: hint.Name,
|
||||||
durationInMilliseconds: response.RunTimeTicks / 10000,
|
duration: hint.RunTimeTicks / 10000,
|
||||||
jellyfinId: response.Id,
|
remoteImages: {},
|
||||||
streamUrl: this.jellyfinStreamBuilderService.buildStreamUrl(
|
} as GenericTrack;
|
||||||
response.Id,
|
} catch (err) {
|
||||||
96000,
|
this.logger.error('TODO');
|
||||||
),
|
|
||||||
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 resolvedTracks = await Promise.all(tracks);
|
||||||
|
const playlist = this.playbackService.getPlaylistOrDefault();
|
||||||
|
playlist.enqueueTracks(resolvedTracks);
|
||||||
break;
|
break;
|
||||||
case SessionMessageType[SessionMessageType.Playstate]:
|
case SessionMessageType[SessionMessageType.Playstate]:
|
||||||
const sendPlaystateCommandRequest =
|
const sendPlaystateCommandRequest =
|
||||||
|
@ -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';
|
import { Injectable } from '@nestjs/common/decorators';
|
||||||
|
|
||||||
@ -7,18 +7,19 @@ import { CommandInteraction } from 'discord.js';
|
|||||||
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 { DiscordVoiceService } from '../clients/discord/discord.voice.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
@Command({
|
@Command({
|
||||||
name: 'disconnect',
|
name: 'disconnect',
|
||||||
description: 'Join your current voice channel',
|
description: 'Join your current voice channel',
|
||||||
})
|
})
|
||||||
@Injectable()
|
export class DisconnectCommand {
|
||||||
export class DisconnectCommand implements DiscordCommand {
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly discordVoiceService: DiscordVoiceService,
|
private readonly discordVoiceService: DiscordVoiceService,
|
||||||
private readonly discordMessageService: DiscordMessageService,
|
private readonly discordMessageService: DiscordMessageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handler(interaction: CommandInteraction): Promise<void> {
|
@Handler()
|
||||||
|
async handler(@IA() interaction: CommandInteraction): Promise<void> {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
embeds: [
|
embeds: [
|
||||||
this.discordMessageService.buildMessage({
|
this.discordMessageService.buildMessage({
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Command, DiscordCommand } from '@discord-nestjs/core';
|
import { Command, Handler, IA } from '@discord-nestjs/core';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
@ -6,15 +6,16 @@ import { CommandInteraction } from 'discord.js';
|
|||||||
|
|
||||||
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
@Command({
|
@Command({
|
||||||
name: 'help',
|
name: 'help',
|
||||||
description: 'Get help if you're having problems with this bot',
|
description: 'Get help if you're having problems with this bot',
|
||||||
})
|
})
|
||||||
@Injectable()
|
export class HelpCommand {
|
||||||
export class HelpCommand implements DiscordCommand {
|
|
||||||
constructor(private readonly discordMessageService: DiscordMessageService) {}
|
constructor(private readonly discordMessageService: DiscordMessageService) {}
|
||||||
|
|
||||||
async handler(interaction: CommandInteraction): Promise<void> {
|
@Handler()
|
||||||
|
async handler(@IA() interaction: CommandInteraction): Promise<void> {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
embeds: [
|
embeds: [
|
||||||
this.discordMessageService.buildMessage({
|
this.discordMessageService.buildMessage({
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Command, DiscordCommand } from '@discord-nestjs/core';
|
import { Command, Handler, IA } from '@discord-nestjs/core';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
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',
|
description: 'Go to the next track in the playlist',
|
||||||
})
|
})
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SkipTrackCommand implements DiscordCommand {
|
export class SkipTrackCommand {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly playbackService: PlaybackService,
|
private readonly playbackService: PlaybackService,
|
||||||
private readonly discordMessageService: DiscordMessageService,
|
private readonly discordMessageService: DiscordMessageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handler(interaction: CommandInteraction): Promise<void> {
|
@Handler()
|
||||||
if (!this.playbackService.nextTrack()) {
|
async handler(@IA() interaction: CommandInteraction): Promise<void> {
|
||||||
|
if (!this.playbackService.getPlaylistOrDefault().hasActiveTrack()) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
embeds: [
|
embeds: [
|
||||||
this.discordMessageService.buildErrorMessage({
|
this.discordMessageService.buildErrorMessage({
|
||||||
@ -27,8 +28,10 @@ export class SkipTrackCommand implements DiscordCommand {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.playbackService.getPlaylistOrDefault().setNextTrackAsActiveTrack();
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
embeds: [
|
embeds: [
|
||||||
this.discordMessageService.buildMessage({
|
this.discordMessageService.buildMessage({
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Command, DiscordCommand } from '@discord-nestjs/core';
|
import { Command, Handler, IA } from '@discord-nestjs/core';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
@ -7,18 +7,19 @@ import { CommandInteraction } from 'discord.js';
|
|||||||
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 { DiscordVoiceService } from '../clients/discord/discord.voice.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
@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',
|
||||||
})
|
})
|
||||||
@Injectable()
|
export class PausePlaybackCommand {
|
||||||
export class PausePlaybackCommand implements DiscordCommand {
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly discordVoiceService: DiscordVoiceService,
|
private readonly discordVoiceService: DiscordVoiceService,
|
||||||
private readonly discordMessageService: DiscordMessageService,
|
private readonly discordMessageService: DiscordMessageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handler(interaction: CommandInteraction): Promise<void> {
|
@Handler()
|
||||||
|
async handler(@IA() interaction: CommandInteraction): Promise<void> {
|
||||||
const shouldBePaused = this.discordVoiceService.togglePaused();
|
const shouldBePaused = this.discordVoiceService.togglePaused();
|
||||||
|
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
|
@ -1,17 +1,19 @@
|
|||||||
|
import { SlashCommandPipe } from '@discord-nestjs/common';
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
DiscordTransformedCommand,
|
Handler,
|
||||||
|
IA,
|
||||||
|
InteractionEvent,
|
||||||
On,
|
On,
|
||||||
Payload,
|
|
||||||
TransformedCommandExecutionContext,
|
|
||||||
} from '@discord-nestjs/core';
|
} from '@discord-nestjs/core';
|
||||||
|
|
||||||
import { RemoteImageResult } from '@jellyfin/sdk/lib/generated-client/models';
|
import { RemoteImageResult } from '@jellyfin/sdk/lib/generated-client/models';
|
||||||
|
|
||||||
import { Logger } from '@nestjs/common/services';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Logger } from '@nestjs/common/services';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
CommandInteraction,
|
||||||
ComponentType,
|
ComponentType,
|
||||||
Events,
|
Events,
|
||||||
GuildMember,
|
GuildMember,
|
||||||
@ -29,17 +31,16 @@ import { DiscordMessageService } from '../clients/discord/discord.message.servic
|
|||||||
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
|
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
|
||||||
import { JellyfinSearchService } from '../clients/jellyfin/jellyfin.search.service';
|
import { JellyfinSearchService } from '../clients/jellyfin/jellyfin.search.service';
|
||||||
import { JellyfinStreamBuilderService } from '../clients/jellyfin/jellyfin.stream.builder.service';
|
import { JellyfinStreamBuilderService } from '../clients/jellyfin/jellyfin.stream.builder.service';
|
||||||
|
import { GenericTrack } from '../models/shared/GenericTrack';
|
||||||
import { chooseSuitableRemoteImage } from '../utils/remoteImages/remoteImages';
|
import { chooseSuitableRemoteImage } from '../utils/remoteImages/remoteImages';
|
||||||
import { trimStringToFixedLength } from '../utils/stringUtils/stringUtils';
|
import { trimStringToFixedLength } from '../utils/stringUtils/stringUtils';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
@Command({
|
@Command({
|
||||||
name: 'play',
|
name: 'play',
|
||||||
description: 'Search for an item on your Jellyfin instance',
|
description: 'Search for an item on your Jellyfin instance',
|
||||||
})
|
})
|
||||||
@Injectable()
|
export class PlayItemCommand {
|
||||||
export class PlayItemCommand
|
|
||||||
implements DiscordTransformedCommand<TrackRequestDto>
|
|
||||||
{
|
|
||||||
private readonly logger: Logger = new Logger(PlayItemCommand.name);
|
private readonly logger: Logger = new Logger(PlayItemCommand.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -50,11 +51,12 @@ export class PlayItemCommand
|
|||||||
private readonly jellyfinStreamBuilder: JellyfinStreamBuilderService,
|
private readonly jellyfinStreamBuilder: JellyfinStreamBuilderService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@Handler()
|
||||||
async handler(
|
async handler(
|
||||||
@Payload() dto: TrackRequestDto,
|
@InteractionEvent(SlashCommandPipe) dto: TrackRequestDto,
|
||||||
executionContext: TransformedCommandExecutionContext<any>,
|
@IA() interaction: CommandInteraction,
|
||||||
): Promise<InteractionReplyOptions | string> {
|
): Promise<InteractionReplyOptions | string> {
|
||||||
await executionContext.interaction.deferReply();
|
await interaction.deferReply();
|
||||||
|
|
||||||
const items = await this.jellyfinSearchService.search(dto.search);
|
const items = await this.jellyfinSearchService.search(dto.search);
|
||||||
const parsedItems = await Promise.all(
|
const parsedItems = await Promise.all(
|
||||||
@ -69,7 +71,7 @@ export class PlayItemCommand
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (parsedItems.length === 0) {
|
if (parsedItems.length === 0) {
|
||||||
await executionContext.interaction.followUp({
|
await interaction.followUp({
|
||||||
embeds: [
|
embeds: [
|
||||||
this.discordMessageService.buildErrorMessage({
|
this.discordMessageService.buildErrorMessage({
|
||||||
title: 'No results for your search query found',
|
title: 'No results for your search query found',
|
||||||
@ -109,7 +111,7 @@ export class PlayItemCommand
|
|||||||
emoji: item.getEmoji(),
|
emoji: item.getEmoji(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await executionContext.interaction.followUp({
|
await interaction.followUp({
|
||||||
embeds: [
|
embeds: [
|
||||||
this.discordMessageService.buildMessage({
|
this.discordMessageService.buildMessage({
|
||||||
title: 'Jellyfin Search Results',
|
title: 'Jellyfin Search Results',
|
||||||
@ -184,8 +186,6 @@ export class PlayItemCommand
|
|||||||
|
|
||||||
this.logger.debug('Successfully joined the voice channel');
|
this.logger.debug('Successfully joined the voice channel');
|
||||||
|
|
||||||
const bitrate = guildMember.voice.channel.bitrate;
|
|
||||||
|
|
||||||
const valueParts = interaction.values[0].split('_');
|
const valueParts = interaction.values[0].split('_');
|
||||||
const type = valueParts[0];
|
const type = valueParts[0];
|
||||||
const id = valueParts[1];
|
const id = valueParts[1];
|
||||||
@ -206,7 +206,6 @@ export class PlayItemCommand
|
|||||||
);
|
);
|
||||||
const addedIndex = this.enqueueSingleTrack(
|
const addedIndex = this.enqueueSingleTrack(
|
||||||
item as BaseJellyfinAudioPlayable,
|
item as BaseJellyfinAudioPlayable,
|
||||||
bitrate,
|
|
||||||
remoteImagesOfCurrentAlbum,
|
remoteImagesOfCurrentAlbum,
|
||||||
);
|
);
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
@ -234,7 +233,6 @@ export class PlayItemCommand
|
|||||||
album.SearchHints.forEach((item) => {
|
album.SearchHints.forEach((item) => {
|
||||||
this.enqueueSingleTrack(
|
this.enqueueSingleTrack(
|
||||||
item as BaseJellyfinAudioPlayable,
|
item as BaseJellyfinAudioPlayable,
|
||||||
bitrate,
|
|
||||||
remoteImages,
|
remoteImages,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -267,7 +265,6 @@ export class PlayItemCommand
|
|||||||
addedRemoteImages.Images.concat(remoteImages.Images);
|
addedRemoteImages.Images.concat(remoteImages.Images);
|
||||||
this.enqueueSingleTrack(
|
this.enqueueSingleTrack(
|
||||||
item as BaseJellyfinAudioPlayable,
|
item as BaseJellyfinAudioPlayable,
|
||||||
bitrate,
|
|
||||||
remoteImages,
|
remoteImages,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -310,22 +307,15 @@ export class PlayItemCommand
|
|||||||
|
|
||||||
private enqueueSingleTrack(
|
private enqueueSingleTrack(
|
||||||
jellyfinPlayable: BaseJellyfinAudioPlayable,
|
jellyfinPlayable: BaseJellyfinAudioPlayable,
|
||||||
bitrate: number,
|
|
||||||
remoteImageResult: RemoteImageResult,
|
remoteImageResult: RemoteImageResult,
|
||||||
) {
|
) {
|
||||||
const stream = this.jellyfinStreamBuilder.buildStreamUrl(
|
return this.playbackService
|
||||||
jellyfinPlayable.Id,
|
.getPlaylistOrDefault()
|
||||||
bitrate,
|
.enqueueTracks([
|
||||||
);
|
GenericTrack.constructFromJellyfinPlayable(
|
||||||
|
jellyfinPlayable,
|
||||||
const milliseconds = jellyfinPlayable.RunTimeTicks / 10000;
|
remoteImageResult,
|
||||||
|
),
|
||||||
return this.playbackService.enqueueTrack({
|
]);
|
||||||
jellyfinId: jellyfinPlayable.Id,
|
|
||||||
name: jellyfinPlayable.Name,
|
|
||||||
durationInMilliseconds: milliseconds,
|
|
||||||
streamUrl: stream,
|
|
||||||
remoteImages: remoteImageResult,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Command, DiscordCommand } from '@discord-nestjs/core';
|
import { Command, Handler, IA } from '@discord-nestjs/core';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
@ -8,24 +8,24 @@ import { PlaybackService } from '../playback/playback.service';
|
|||||||
import { Constants } from '../utils/constants';
|
import { Constants } from '../utils/constants';
|
||||||
import { formatMillisecondsAsHumanReadable } from '../utils/timeUtils';
|
import { formatMillisecondsAsHumanReadable } from '../utils/timeUtils';
|
||||||
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
||||||
import { chooseSuitableRemoteImageFromTrack } from '../utils/remoteImages/remoteImages';
|
|
||||||
import { trimStringToFixedLength } from '../utils/stringUtils/stringUtils';
|
import { trimStringToFixedLength } from '../utils/stringUtils/stringUtils';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
@Command({
|
@Command({
|
||||||
name: 'playlist',
|
name: 'playlist',
|
||||||
description: 'Print the current track information',
|
description: 'Print the current track information',
|
||||||
})
|
})
|
||||||
@Injectable()
|
export class PlaylistCommand {
|
||||||
export class PlaylistCommand implements DiscordCommand {
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly discordMessageService: DiscordMessageService,
|
private readonly discordMessageService: DiscordMessageService,
|
||||||
private readonly playbackService: PlaybackService,
|
private readonly playbackService: PlaybackService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handler(interaction: CommandInteraction): Promise<void> {
|
@Handler()
|
||||||
const playList = this.playbackService.getPlaylist();
|
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({
|
await interaction.reply({
|
||||||
embeds: [
|
embeds: [
|
||||||
this.discordMessageService.buildMessage({
|
this.discordMessageService.buildMessage({
|
||||||
@ -35,15 +35,16 @@ export class PlaylistCommand implements DiscordCommand {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tracklist = playList.tracks
|
const tracklist = playlist.tracks
|
||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
.map((track, index) => {
|
.map((track, index) => {
|
||||||
const isCurrent = track.id === playList.activeTrack;
|
const isCurrent = track === playlist.getActiveTrack();
|
||||||
|
|
||||||
let point = this.getListPoint(isCurrent, index);
|
let point = this.getListPoint(isCurrent, index);
|
||||||
point += `**${trimStringToFixedLength(track.track.name, 30)}**`;
|
point += `**${trimStringToFixedLength(track.name, 30)}**`;
|
||||||
|
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
point += ' :loud_sound:';
|
point += ' :loud_sound:';
|
||||||
@ -52,16 +53,13 @@ export class PlaylistCommand implements DiscordCommand {
|
|||||||
point += '\n';
|
point += '\n';
|
||||||
point += Constants.Design.InvisibleSpace.repeat(2);
|
point += Constants.Design.InvisibleSpace.repeat(2);
|
||||||
point += 'Duration: ';
|
point += 'Duration: ';
|
||||||
point += formatMillisecondsAsHumanReadable(
|
point += formatMillisecondsAsHumanReadable(track.getDuration());
|
||||||
track.track.durationInMilliseconds,
|
|
||||||
);
|
|
||||||
|
|
||||||
return point;
|
return point;
|
||||||
})
|
})
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
// const remoteImage = chooseSuitableRemoteImageFromTrack(playlist.getActiveTrack());
|
||||||
const activeTrack = this.playbackService.getActiveTrack();
|
const remoteImage = undefined;
|
||||||
const remoteImage = chooseSuitableRemoteImageFromTrack(activeTrack.track);
|
|
||||||
|
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
embeds: [
|
embeds: [
|
||||||
|
@ -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';
|
import { Injectable } from '@nestjs/common/decorators';
|
||||||
|
|
||||||
@ -7,19 +7,20 @@ import { CommandInteraction } from 'discord.js';
|
|||||||
import { PlaybackService } from '../playback/playback.service';
|
import { PlaybackService } from '../playback/playback.service';
|
||||||
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
@Command({
|
@Command({
|
||||||
name: 'previous',
|
name: 'previous',
|
||||||
description: 'Go to the previous track',
|
description: 'Go to the previous track',
|
||||||
})
|
})
|
||||||
@Injectable()
|
export class PreviousTrackCommand {
|
||||||
export class PreviousTrackCommand implements DiscordCommand {
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly playbackService: PlaybackService,
|
private readonly playbackService: PlaybackService,
|
||||||
private readonly discordMessageService: DiscordMessageService,
|
private readonly discordMessageService: DiscordMessageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handler(interaction: CommandInteraction): Promise<void> {
|
@Handler()
|
||||||
if (!this.playbackService.previousTrack()) {
|
async handler(@IA() interaction: CommandInteraction): Promise<void> {
|
||||||
|
if (!this.playbackService.getPlaylistOrDefault().hasActiveTrack()) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
embeds: [
|
embeds: [
|
||||||
this.discordMessageService.buildErrorMessage({
|
this.discordMessageService.buildErrorMessage({
|
||||||
@ -27,8 +28,10 @@ export class PreviousTrackCommand implements DiscordCommand {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.playbackService.getPlaylistOrDefault().setPreviousTrackAsActiveTrack();
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
embeds: [
|
embeds: [
|
||||||
this.discordMessageService.buildMessage({
|
this.discordMessageService.buildMessage({
|
||||||
|
@ -1,8 +1,4 @@
|
|||||||
import {
|
import { Command, Handler, IA, InjectDiscordClient } from '@discord-nestjs/core';
|
||||||
Command,
|
|
||||||
DiscordCommand,
|
|
||||||
InjectDiscordClient,
|
|
||||||
} from '@discord-nestjs/core';
|
|
||||||
|
|
||||||
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
|
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',
|
description: 'Display the current status for troubleshooting',
|
||||||
})
|
})
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StatusCommand implements DiscordCommand {
|
export class StatusCommand {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectDiscordClient()
|
@InjectDiscordClient()
|
||||||
private readonly client: Client,
|
private readonly client: Client,
|
||||||
@ -29,7 +25,8 @@ export class StatusCommand implements DiscordCommand {
|
|||||||
private readonly jellyfinService: JellyfinService,
|
private readonly jellyfinService: JellyfinService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handler(interaction: CommandInteraction): Promise<void> {
|
@Handler()
|
||||||
|
async handler(@IA() interaction: CommandInteraction): Promise<void> {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
embeds: [
|
embeds: [
|
||||||
this.discordMessageService.buildMessage({
|
this.discordMessageService.buildMessage({
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Command, DiscordCommand } from '@discord-nestjs/core';
|
import { Command, Handler, IA } from '@discord-nestjs/core';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
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',
|
description: 'Stop playback entirely and clear the current playlist',
|
||||||
})
|
})
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StopPlaybackCommand implements DiscordCommand {
|
export class StopPlaybackCommand {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly playbackService: PlaybackService,
|
private readonly playbackService: PlaybackService,
|
||||||
private readonly discordMessageService: DiscordMessageService,
|
private readonly discordMessageService: DiscordMessageService,
|
||||||
private readonly discordVoiceService: DiscordVoiceService,
|
private readonly discordVoiceService: DiscordVoiceService,
|
||||||
) {}
|
) {}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
async handler(interaction: CommandInteraction): Promise<void> {
|
@Handler()
|
||||||
const hasActiveTrack = this.playbackService.hasActiveTrack();
|
async handler(@IA() interaction: CommandInteraction): Promise<void> {
|
||||||
|
const hasActiveTrack = this.playbackService.getPlaylistOrDefault();
|
||||||
const title = hasActiveTrack
|
const title = hasActiveTrack
|
||||||
? 'Playback stopped successfully'
|
? 'Playback stopped successfully'
|
||||||
: 'Playback failed to stop';
|
: 'Playback failed to stop';
|
||||||
@ -29,7 +30,7 @@ export class StopPlaybackCommand implements DiscordCommand {
|
|||||||
? 'In addition, your playlist has been cleared'
|
? 'In addition, your playlist has been cleared'
|
||||||
: 'There is no active track in the queue';
|
: 'There is no active track in the queue';
|
||||||
if (hasActiveTrack) {
|
if (hasActiveTrack) {
|
||||||
this.playbackService.clear();
|
this.playbackService.getPlaylistOrDefault().clear();
|
||||||
this.discordVoiceService.stop(false);
|
this.discordVoiceService.stop(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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';
|
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 { DiscordMessageService } from '../clients/discord/discord.message.service';
|
||||||
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
|
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
@Command({
|
@Command({
|
||||||
name: 'summon',
|
name: 'summon',
|
||||||
description: 'Join your current voice channel',
|
description: 'Join your current voice channel',
|
||||||
})
|
})
|
||||||
@Injectable()
|
export class SummonCommand {
|
||||||
export class SummonCommand implements DiscordCommand {
|
|
||||||
private readonly logger = new Logger(SummonCommand.name);
|
private readonly logger = new Logger(SummonCommand.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -20,7 +20,8 @@ export class SummonCommand implements DiscordCommand {
|
|||||||
private readonly discordMessageService: DiscordMessageService,
|
private readonly discordMessageService: DiscordMessageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handler(interaction: CommandInteraction): Promise<void> {
|
@Handler()
|
||||||
|
async handler(@IA() interaction: CommandInteraction): Promise<void> {
|
||||||
await interaction.deferReply();
|
await interaction.deferReply();
|
||||||
|
|
||||||
const guildMember = interaction.member as GuildMember;
|
const guildMember = interaction.member as GuildMember;
|
||||||
|
@ -1,29 +1,18 @@
|
|||||||
import {
|
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from '@nestjs/common';
|
||||||
Catch,
|
|
||||||
DiscordArgumentMetadata,
|
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, CommandInteraction } from 'discord.js';
|
||||||
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 { Constants } from '../utils/constants';
|
import { Constants } from '../utils/constants';
|
||||||
|
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
||||||
|
|
||||||
@Catch(Error)
|
@Catch(Error)
|
||||||
export class CommandExecutionError implements DiscordExceptionFilter {
|
export class CommandExecutionError implements ExceptionFilter {
|
||||||
private readonly logger = new Logger(CommandExecutionError.name);
|
private readonly logger = new Logger(CommandExecutionError.name);
|
||||||
|
|
||||||
constructor(private readonly discordMessageService: DiscordMessageService) {}
|
constructor(private readonly discordMessageService: DiscordMessageService) {}
|
||||||
|
|
||||||
async catch(
|
async catch(exception: Error, host: ArgumentsHost): Promise<void> {
|
||||||
exception: Error,
|
const interaction = host.getArgByIndex(0) as CommandInteraction;
|
||||||
metadata: DiscordArgumentMetadata<string, any>,
|
|
||||||
): Promise<void> {
|
|
||||||
const interaction: CommandInteraction = metadata.eventArgs[0];
|
|
||||||
|
|
||||||
if (!interaction.isCommand()) {
|
if (!interaction.isCommand()) {
|
||||||
return;
|
return;
|
||||||
@ -34,6 +23,10 @@ export class CommandExecutionError implements DiscordExceptionFilter {
|
|||||||
exception.stack,
|
exception.stack,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!interaction.isRepliable()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setLabel('Report this issue')
|
.setLabel('Report this issue')
|
||||||
|
129
src/models/shared/GenericPlaylist.ts
Normal file
129
src/models/shared/GenericPlaylist.ts
Normal 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';
|
59
src/models/shared/GenericTrack.ts
Normal file
59
src/models/shared/GenericTrack.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,143 +1,21 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Playlist } from '../types/playlist';
|
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||||
import { Track } from '../types/track';
|
|
||||||
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { GenericPlaylist } from '../models/shared/GenericPlaylist';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PlaybackService {
|
export class PlaybackService {
|
||||||
private readonly logger = new Logger(PlaybackService.name);
|
private readonly logger = new Logger(PlaybackService.name);
|
||||||
|
private playlist: GenericPlaylist | undefined = undefined;
|
||||||
private readonly playlist: Playlist = {
|
|
||||||
tracks: [],
|
|
||||||
activeTrack: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(private readonly eventEmitter: EventEmitter2) {}
|
constructor(private readonly eventEmitter: EventEmitter2) {}
|
||||||
|
|
||||||
getActiveTrack() {
|
getPlaylistOrDefault(): GenericPlaylist {
|
||||||
return this.getTrackById(this.playlist.activeTrack);
|
if (this.playlist) {
|
||||||
}
|
|
||||||
|
|
||||||
setActiveTrack(trackId: string) {
|
|
||||||
const track = this.getTrackById(trackId);
|
|
||||||
|
|
||||||
if (!track) {
|
|
||||||
throw Error('track is not in 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 {
|
|
||||||
return this.playlist;
|
return this.playlist;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getTrackById(id: string) {
|
this.playlist = new GenericPlaylist(this.eventEmitter);
|
||||||
return this.playlist.tracks.find((x) => x.id === id);
|
return this.playlist;
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -368,10 +368,10 @@
|
|||||||
class-transformer "0.5.1"
|
class-transformer "0.5.1"
|
||||||
class-validator "0.14.0"
|
class-validator "0.14.0"
|
||||||
|
|
||||||
"@discord-nestjs/core@^4.3.1":
|
"@discord-nestjs/core@^5.3.0":
|
||||||
version "4.3.1"
|
version "5.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/@discord-nestjs/core/-/core-4.3.1.tgz#b0a71834147d2bfac8efe37b7091667ce7f146d6"
|
resolved "https://registry.yarnpkg.com/@discord-nestjs/core/-/core-5.3.0.tgz#8e93b0310e8cc2c0cde74c6317d949d6e9d28d2d"
|
||||||
integrity sha512-38Bk7V0W+LF2qUbE6K+CfqRR309jU/tbj6ZZLN97nFHcYCcsteKQ7HxJeM15pw09Vvb7xbB+3DIls/YHpq3lRA==
|
integrity sha512-eHVzuPCu3EbQyTln+ZEH0/Jwe0xPG7Z1eZV655jZoCSpq7RmvxWcTG/REf3XMSgtYFN27HaXa9vPCsgUOnp9xQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
class-transformer "0.5.1"
|
class-transformer "0.5.1"
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user