mirror of
https://github.com/informaticker/discord-jellyfin-bot.git
synced 2024-11-25 02:51:57 +01:00
✨ Add paged playlist (#106)
This commit is contained in:
parent
12065e6c90
commit
916969f07b
@ -21,8 +21,8 @@
|
|||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discord-nestjs/common": "^5.2.0",
|
"@discord-nestjs/common": "^5.2.1",
|
||||||
"@discord-nestjs/core": "^5.3.0",
|
"@discord-nestjs/core": "^5.3.3",
|
||||||
"@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",
|
||||||
|
@ -24,6 +24,9 @@ import { JellyfinClientModule } from './clients/jellyfin/jellyfin.module';
|
|||||||
JELLYFIN_AUTHENTICATION_USERNAME: Joi.string().required(),
|
JELLYFIN_AUTHENTICATION_USERNAME: Joi.string().required(),
|
||||||
JELLYFIN_AUTHENTICATION_PASSWORD: Joi.string().required(),
|
JELLYFIN_AUTHENTICATION_PASSWORD: Joi.string().required(),
|
||||||
UPDATER_DISABLE_NOTIFICATIONS: Joi.boolean(),
|
UPDATER_DISABLE_NOTIFICATIONS: Joi.boolean(),
|
||||||
|
LOG_LEVEL: Joi.string()
|
||||||
|
.valid('error', 'warn', 'log', 'debug', 'verbose')
|
||||||
|
.default('log'),
|
||||||
PORT: Joi.number().min(1),
|
PORT: Joi.number().min(1),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
@ -22,7 +22,7 @@ import { JellyfinStreamBuilderService } from '../jellyfin/jellyfin.stream.builde
|
|||||||
import { JellyfinWebSocketService } from '../jellyfin/jellyfin.websocket.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 { GenericTrack } from '../../models/shared/GenericTrack';
|
import { Track } from '../../models/shared/Track';
|
||||||
|
|
||||||
import { DiscordMessageService } from './discord.message.service';
|
import { DiscordMessageService } from './discord.message.service';
|
||||||
|
|
||||||
@ -41,7 +41,7 @@ export class DiscordVoiceService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@OnEvent('internal.audio.announce')
|
@OnEvent('internal.audio.announce')
|
||||||
handleOnNewTrack(track: GenericTrack) {
|
handleOnNewTrack(track: Track) {
|
||||||
const resource = createAudioResource(
|
const resource = createAudioResource(
|
||||||
track.getStreamUrl(this.jellyfinStreamBuilder),
|
track.getStreamUrl(this.jellyfinStreamBuilder),
|
||||||
);
|
);
|
||||||
@ -69,7 +69,7 @@ export class DiscordVoiceService {
|
|||||||
success: false,
|
success: false,
|
||||||
reply: {
|
reply: {
|
||||||
embeds: [
|
embeds: [
|
||||||
this.discordMessageService.buildErrorMessage({
|
this.discordMessageService.buildMessage({
|
||||||
title: 'Unable to join your channel',
|
title: 'Unable to join your channel',
|
||||||
description:
|
description:
|
||||||
"I am unable to join your channel, because you don't seem to be in a voice channel. Connect to a channel first to use this command",
|
"I am unable to join your channel, because you don't seem to be in a voice channel. Connect to a channel first to use this command",
|
||||||
|
@ -134,6 +134,10 @@ export class JellyfinSearchService {
|
|||||||
const api = this.jellyfinService.getApi();
|
const api = this.jellyfinService.getApi();
|
||||||
const remoteImageApi = getRemoteImageApi(api);
|
const remoteImageApi = getRemoteImageApi(api);
|
||||||
|
|
||||||
|
this.logger.verbose(
|
||||||
|
`Searching for remote images of item '${id}' with limit of ${limit}`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const axiosReponse = await remoteImageApi.getRemoteImages({
|
const axiosReponse = await remoteImageApi.getRemoteImages({
|
||||||
itemId: id,
|
itemId: id,
|
||||||
@ -151,6 +155,10 @@ export class JellyfinSearchService {
|
|||||||
TotalRecordCount: 0,
|
TotalRecordCount: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.logger.verbose(
|
||||||
|
`Retrieved ${axiosReponse.data.TotalRecordCount} remote images from Jellyfin`,
|
||||||
|
);
|
||||||
return axiosReponse.data;
|
return axiosReponse.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Failed to retrieve remote images: ${err}`);
|
this.logger.error(`Failed to retrieve remote images: ${err}`);
|
||||||
|
@ -14,7 +14,7 @@ import {
|
|||||||
PlayNowCommand,
|
PlayNowCommand,
|
||||||
SessionApiSendPlaystateCommandRequest,
|
SessionApiSendPlaystateCommandRequest,
|
||||||
} from '../../types/websocket';
|
} from '../../types/websocket';
|
||||||
import { GenericTrack } from '../../models/shared/GenericTrack';
|
import { Track } from '../../models/shared/Track';
|
||||||
|
|
||||||
import { JellyfinSearchService } from './jellyfin.search.service';
|
import { JellyfinSearchService } from './jellyfin.search.service';
|
||||||
import { JellyfinService } from './jellyfin.service';
|
import { JellyfinService } from './jellyfin.service';
|
||||||
|
@ -4,7 +4,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { DiscordClientModule } from '../clients/discord/discord.module';
|
import { DiscordClientModule } from '../clients/discord/discord.module';
|
||||||
import { JellyfinClientModule } from '../clients/jellyfin/jellyfin.module';
|
import { JellyfinClientModule } from '../clients/jellyfin/jellyfin.module';
|
||||||
import { PlaybackModule } from '../playback/playback.module';
|
import { PlaybackModule } from '../playback/playback.module';
|
||||||
import { PlaylistCommand } from './playlist.command';
|
import { PlaylistCommand } from './playlist/playlist.command';
|
||||||
import { DisconnectCommand } from './disconnect.command';
|
import { DisconnectCommand } from './disconnect.command';
|
||||||
import { HelpCommand } from './help.command';
|
import { HelpCommand } from './help.command';
|
||||||
import { PausePlaybackCommand } from './pause.command';
|
import { PausePlaybackCommand } from './pause.command';
|
||||||
@ -14,6 +14,7 @@ import { SkipTrackCommand } from './next.command';
|
|||||||
import { StatusCommand } from './status.command';
|
import { StatusCommand } from './status.command';
|
||||||
import { StopPlaybackCommand } from './stop.command';
|
import { StopPlaybackCommand } from './stop.command';
|
||||||
import { SummonCommand } from './summon.command';
|
import { SummonCommand } from './summon.command';
|
||||||
|
import { PlaylistInteractionCollector } from './playlist/playlist.interaction-collector';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -24,6 +25,7 @@ import { SummonCommand } from './summon.command';
|
|||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [
|
||||||
|
PlaylistInteractionCollector,
|
||||||
HelpCommand,
|
HelpCommand,
|
||||||
StatusCommand,
|
StatusCommand,
|
||||||
PlaylistCommand,
|
PlaylistCommand,
|
||||||
|
@ -48,7 +48,7 @@ export class PlayItemCommand {
|
|||||||
@InteractionEvent(SlashCommandPipe) dto: TrackRequestDto,
|
@InteractionEvent(SlashCommandPipe) dto: TrackRequestDto,
|
||||||
@IA() interaction: CommandInteraction,
|
@IA() interaction: CommandInteraction,
|
||||||
): Promise<InteractionReplyOptions | string> {
|
): Promise<InteractionReplyOptions | string> {
|
||||||
await interaction.deferReply();
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
const baseItems = TrackRequestDto.getBaseItemKinds(dto.type);
|
const baseItems = TrackRequestDto.getBaseItemKinds(dto.type);
|
||||||
|
|
||||||
@ -72,6 +72,7 @@ export class PlayItemCommand {
|
|||||||
description: `- Check for any misspellings\n- Grant me access to your desired libraries\n- Avoid special characters`,
|
description: `- Check for any misspellings\n- Grant me access to your desired libraries\n- Avoid special characters`,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
ephemeral: true,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -96,20 +97,18 @@ export class PlayItemCommand {
|
|||||||
(sum, item) => sum + item.duration,
|
(sum, item) => sum + item.duration,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const enqueuedCount = this.playbackService
|
this.playbackService.getPlaylistOrDefault().enqueueTracks(tracks);
|
||||||
.getPlaylistOrDefault()
|
|
||||||
.enqueueTracks(tracks);
|
|
||||||
|
|
||||||
console.log(tracks);
|
|
||||||
|
|
||||||
const remoteImage: RemoteImageInfo | undefined = tracks
|
const remoteImage: RemoteImageInfo | undefined = tracks
|
||||||
.map((x) => x.getRemoteImage())
|
.flatMap((x) => x.getRemoteImages())
|
||||||
.find((x) => true);
|
.find((x) => true);
|
||||||
|
|
||||||
await interaction.followUp({
|
await interaction.followUp({
|
||||||
embeds: [
|
embeds: [
|
||||||
this.discordMessageService.buildMessage({
|
this.discordMessageService.buildMessage({
|
||||||
title: `Added ${enqueuedCount} tracks to your playlist (${formatMillisecondsAsHumanReadable(
|
title: `Added ${this.playbackService
|
||||||
|
.getPlaylistOrDefault()
|
||||||
|
.getLength()} tracks to your playlist (${formatMillisecondsAsHumanReadable(
|
||||||
reducedDuration,
|
reducedDuration,
|
||||||
)})`,
|
)})`,
|
||||||
mixin(embedBuilder) {
|
mixin(embedBuilder) {
|
||||||
@ -120,6 +119,7 @@ export class PlayItemCommand {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
ephemeral: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,88 +0,0 @@
|
|||||||
import { Command, Handler, IA } from '@discord-nestjs/core';
|
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { CommandInteraction } from 'discord.js';
|
|
||||||
|
|
||||||
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 { trimStringToFixedLength } from '../utils/stringUtils/stringUtils';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
@Command({
|
|
||||||
name: 'playlist',
|
|
||||||
description: 'Print the current track information',
|
|
||||||
})
|
|
||||||
export class PlaylistCommand {
|
|
||||||
constructor(
|
|
||||||
private readonly discordMessageService: DiscordMessageService,
|
|
||||||
private readonly playbackService: PlaybackService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Handler()
|
|
||||||
async handler(@IA() interaction: CommandInteraction): Promise<void> {
|
|
||||||
const playlist = this.playbackService.getPlaylistOrDefault();
|
|
||||||
|
|
||||||
if (playlist.isEmpty()) {
|
|
||||||
await interaction.reply({
|
|
||||||
embeds: [
|
|
||||||
this.discordMessageService.buildMessage({
|
|
||||||
title: 'Your Playlist',
|
|
||||||
description:
|
|
||||||
'You do not have any tracks in your playlist.\nUse the ``/play`` command to add new tracks to your playlist',
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tracklist = playlist.tracks
|
|
||||||
.map((track, index) => {
|
|
||||||
const isCurrent = track === playlist.getActiveTrack();
|
|
||||||
|
|
||||||
let point = this.getListPoint(isCurrent, index);
|
|
||||||
point += `**${trimStringToFixedLength(track.name, 30)}**`;
|
|
||||||
|
|
||||||
if (isCurrent) {
|
|
||||||
point += ' :loud_sound:';
|
|
||||||
}
|
|
||||||
|
|
||||||
point += '\n';
|
|
||||||
point += Constants.Design.InvisibleSpace.repeat(2);
|
|
||||||
point += formatMillisecondsAsHumanReadable(track.getDuration());
|
|
||||||
|
|
||||||
return point;
|
|
||||||
})
|
|
||||||
.slice(0, 10)
|
|
||||||
.join('\n');
|
|
||||||
const remoteImage = undefined;
|
|
||||||
|
|
||||||
await interaction.reply({
|
|
||||||
embeds: [
|
|
||||||
this.discordMessageService.buildMessage({
|
|
||||||
title: 'Your Playlist',
|
|
||||||
description: `${tracklist}\n\nUse the /skip and /previous command to select a track`,
|
|
||||||
mixin(embedBuilder) {
|
|
||||||
if (remoteImage === undefined) {
|
|
||||||
return embedBuilder;
|
|
||||||
}
|
|
||||||
|
|
||||||
return embedBuilder.setThumbnail(remoteImage.Url);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private getListPoint(isCurrent: boolean, index: number) {
|
|
||||||
if (isCurrent) {
|
|
||||||
return `${index + 1}. `;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${index + 1}. `;
|
|
||||||
}
|
|
||||||
}
|
|
181
src/commands/playlist/playlist.command.ts
Normal file
181
src/commands/playlist/playlist.command.ts
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
import { CollectorInterceptor, SlashCommandPipe } from '@discord-nestjs/common';
|
||||||
|
import {
|
||||||
|
AppliedCollectors,
|
||||||
|
Command,
|
||||||
|
Handler,
|
||||||
|
IA,
|
||||||
|
InteractionEvent,
|
||||||
|
Param,
|
||||||
|
ParamType,
|
||||||
|
UseCollectors,
|
||||||
|
} from '@discord-nestjs/core';
|
||||||
|
|
||||||
|
import { Injectable, Logger, UseInterceptors } from '@nestjs/common';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonInteraction,
|
||||||
|
ButtonStyle,
|
||||||
|
CommandInteraction,
|
||||||
|
EmbedBuilder,
|
||||||
|
InteractionCollector,
|
||||||
|
InteractionReplyOptions,
|
||||||
|
InteractionUpdateOptions,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
|
import { PlaybackService } from '../../playback/playback.service';
|
||||||
|
import { chunkArray } from '../../utils/arrayUtils';
|
||||||
|
import { Constants } from '../../utils/constants';
|
||||||
|
import { formatMillisecondsAsHumanReadable } from '../../utils/timeUtils';
|
||||||
|
import { DiscordMessageService } from '../../clients/discord/discord.message.service';
|
||||||
|
import { Track } from '../../models/shared/Track';
|
||||||
|
import { trimStringToFixedLength } from '../../utils/stringUtils/stringUtils';
|
||||||
|
|
||||||
|
import { PlaylistInteractionCollector } from './playlist.interaction-collector';
|
||||||
|
|
||||||
|
class PlaylistCommandDto {
|
||||||
|
@Param({
|
||||||
|
required: false,
|
||||||
|
description: 'The page',
|
||||||
|
type: ParamType.INTEGER,
|
||||||
|
})
|
||||||
|
page: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
@Command({
|
||||||
|
name: 'playlist',
|
||||||
|
description: 'Print the current track information',
|
||||||
|
})
|
||||||
|
@UseInterceptors(CollectorInterceptor)
|
||||||
|
@UseCollectors(PlaylistInteractionCollector)
|
||||||
|
export class PlaylistCommand {
|
||||||
|
public pageData: Map<string, number> = new Map();
|
||||||
|
private readonly logger = new Logger(PlaylistCommand.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly discordMessageService: DiscordMessageService,
|
||||||
|
private readonly playbackService: PlaybackService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Handler()
|
||||||
|
async handler(
|
||||||
|
@InteractionEvent(SlashCommandPipe) dto: PlaylistCommandDto,
|
||||||
|
@IA() interaction: CommandInteraction,
|
||||||
|
@AppliedCollectors(0) collector: InteractionCollector<ButtonInteraction>,
|
||||||
|
): Promise<void> {
|
||||||
|
const page = dto.page ?? 0;
|
||||||
|
|
||||||
|
const response = await interaction.reply(
|
||||||
|
this.getReplyForPage(page) as InteractionReplyOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.pageData.set(response.id, page);
|
||||||
|
this.logger.debug(
|
||||||
|
`Added '${interaction.id}' as a message id for page storage`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getChunks() {
|
||||||
|
const playlist = this.playbackService.getPlaylistOrDefault();
|
||||||
|
return chunkArray(playlist.tracks, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getReplyForPage(
|
||||||
|
page: number,
|
||||||
|
): InteractionReplyOptions | InteractionUpdateOptions {
|
||||||
|
const chunks = this.getChunks();
|
||||||
|
|
||||||
|
if (page >= chunks.length) {
|
||||||
|
return {
|
||||||
|
embeds: [
|
||||||
|
this.discordMessageService.buildMessage({
|
||||||
|
title: 'Page does not exist',
|
||||||
|
description: 'Please pass a valid page',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
ephemeral: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentForPage = this.getContentForPage(chunks, page);
|
||||||
|
|
||||||
|
if (!contentForPage) {
|
||||||
|
return {
|
||||||
|
embeds: [
|
||||||
|
this.discordMessageService.buildMessage({
|
||||||
|
title: 'Your Playlist',
|
||||||
|
description:
|
||||||
|
'You do not have any tracks in your playlist.\nUse the ``/play`` command to add new tracks to your playlist',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
ephemeral: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPrevious = page;
|
||||||
|
const hasNext = page + 1 < chunks.length;
|
||||||
|
|
||||||
|
const rowBuilder = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setDisabled(!hasPrevious)
|
||||||
|
.setCustomId('playlist-controls-previous')
|
||||||
|
.setEmoji('◀️')
|
||||||
|
.setLabel('Previous')
|
||||||
|
.setStyle(ButtonStyle.Secondary),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setDisabled(!hasNext)
|
||||||
|
.setCustomId('playlist-controls-next')
|
||||||
|
.setEmoji('▶️')
|
||||||
|
.setLabel('Next')
|
||||||
|
.setStyle(ButtonStyle.Secondary),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
embeds: [contentForPage.toJSON()],
|
||||||
|
ephemeral: true,
|
||||||
|
components: [rowBuilder],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getContentForPage(
|
||||||
|
chunks: Track[][],
|
||||||
|
page: number,
|
||||||
|
): EmbedBuilder | undefined {
|
||||||
|
const playlist = this.playbackService.getPlaylistOrDefault();
|
||||||
|
|
||||||
|
if (page >= chunks.length || page < 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = chunks[page]
|
||||||
|
.map((track, index) => {
|
||||||
|
const isCurrent = track === playlist.getActiveTrack();
|
||||||
|
|
||||||
|
let point = this.getListPoint(isCurrent, index);
|
||||||
|
point += `**${trimStringToFixedLength(track.name, 30)}**`;
|
||||||
|
|
||||||
|
if (isCurrent) {
|
||||||
|
point += ' :loud_sound:';
|
||||||
|
}
|
||||||
|
|
||||||
|
point += '\n';
|
||||||
|
point += Constants.Design.InvisibleSpace.repeat(2);
|
||||||
|
point += formatMillisecondsAsHumanReadable(track.getDuration());
|
||||||
|
|
||||||
|
return point;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return new EmbedBuilder().setTitle('Your playlist').setDescription(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getListPoint(isCurrent: boolean, index: number) {
|
||||||
|
if (isCurrent) {
|
||||||
|
return `${index + 1}. `;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${index + 1}. `;
|
||||||
|
}
|
||||||
|
}
|
78
src/commands/playlist/playlist.interaction-collector.ts
Normal file
78
src/commands/playlist/playlist.interaction-collector.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
Filter,
|
||||||
|
InjectCauseEvent,
|
||||||
|
InteractionEventCollector,
|
||||||
|
On,
|
||||||
|
} from '@discord-nestjs/core';
|
||||||
|
|
||||||
|
import { forwardRef, Inject, Injectable, Scope } from '@nestjs/common';
|
||||||
|
import { Logger } from '@nestjs/common/services';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ButtonInteraction,
|
||||||
|
ChatInputCommandInteraction,
|
||||||
|
InteractionUpdateOptions,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
|
import { PlaylistCommand } from './playlist.command';
|
||||||
|
|
||||||
|
@Injectable({ scope: Scope.REQUEST })
|
||||||
|
@InteractionEventCollector({ time: 15 * 1000 })
|
||||||
|
export class PlaylistInteractionCollector {
|
||||||
|
private readonly logger = new Logger(PlaylistInteractionCollector.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(forwardRef(() => PlaylistCommand))
|
||||||
|
private readonly playlistCommand: PlaylistCommand,
|
||||||
|
@InjectCauseEvent()
|
||||||
|
private readonly causeInteraction: ChatInputCommandInteraction,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Filter()
|
||||||
|
filter(interaction: ButtonInteraction): boolean {
|
||||||
|
return this.causeInteraction.id === interaction.message.interaction.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@On('collect')
|
||||||
|
async onCollect(interaction: ButtonInteraction): Promise<void> {
|
||||||
|
const targetPage = this.getInteraction(interaction);
|
||||||
|
|
||||||
|
if (targetPage === undefined) {
|
||||||
|
await interaction.update({
|
||||||
|
content: 'Unknown error',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Updating current page for interaction ${this.causeInteraction.id} to ${targetPage}`,
|
||||||
|
);
|
||||||
|
this.playlistCommand.pageData.set(this.causeInteraction.id, targetPage);
|
||||||
|
const reply = this.playlistCommand.getReplyForPage(targetPage);
|
||||||
|
await interaction.update(reply as InteractionUpdateOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getInteraction(interaction: ButtonInteraction): number | null {
|
||||||
|
const current = this.playlistCommand.pageData.get(this.causeInteraction.id);
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Retrieved current page from command using id '${
|
||||||
|
this.causeInteraction.id
|
||||||
|
}' in list of ${
|
||||||
|
Object.keys(this.playlistCommand.pageData).length
|
||||||
|
}: ${current}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (interaction.customId) {
|
||||||
|
case 'playlist-controls-next':
|
||||||
|
return current + 1;
|
||||||
|
case 'playlist-controls-previous':
|
||||||
|
return current - 1;
|
||||||
|
default:
|
||||||
|
this.logger.error(
|
||||||
|
`Unable to map button interaction from collector to target page`,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
18
src/main.ts
18
src/main.ts
@ -4,11 +4,21 @@ import { NestFactory } from '@nestjs/core';
|
|||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
function getLoggingLevels(): LogLevel[] {
|
function getLoggingLevels(): LogLevel[] {
|
||||||
if (process.env.DEBUG) {
|
switch (process.env.LOG_LEVEL.toLowerCase()) {
|
||||||
return ['log', 'error', 'warn', 'debug'];
|
case 'error':
|
||||||
|
return ['error'];
|
||||||
|
case 'warn':
|
||||||
|
return ['error', 'warn'];
|
||||||
|
case 'log':
|
||||||
|
return ['error', 'warn', 'log'];
|
||||||
|
case 'debug':
|
||||||
|
return ['error', 'warn', 'log', 'debug'];
|
||||||
|
case 'verbose':
|
||||||
|
return ['error', 'warn', 'log', 'debug', 'verbose'];
|
||||||
|
default:
|
||||||
|
console.log(`failed to process log level ${process.env.LOG_LEVEL}`);
|
||||||
|
return ['error', 'warn', 'log'];
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['log', 'error', 'warn'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { SearchHint as JellyfinSearchHint } from '@jellyfin/sdk/lib/generated-client/models';
|
import { SearchHint as JellyfinSearchHint } from '@jellyfin/sdk/lib/generated-client/models';
|
||||||
|
|
||||||
import { GenericTrack } from '../shared/GenericTrack';
|
import { Track } from '../shared/Track';
|
||||||
import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service';
|
import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service';
|
||||||
|
|
||||||
import { SearchHint } from './SearchHint';
|
import { SearchHint } from './SearchHint';
|
||||||
@ -16,7 +16,7 @@ export class AlbumSearchHint extends SearchHint {
|
|||||||
|
|
||||||
override async toTracks(
|
override async toTracks(
|
||||||
searchService: JellyfinSearchService,
|
searchService: JellyfinSearchService,
|
||||||
): Promise<GenericTrack[]> {
|
): Promise<Track[]> {
|
||||||
const albumItems = await searchService.getAlbumItems(this.id);
|
const albumItems = await searchService.getAlbumItems(this.id);
|
||||||
const tracks = albumItems.map(async (x) =>
|
const tracks = albumItems.map(async (x) =>
|
||||||
(await x.toTracks(searchService)).find((x) => x !== null),
|
(await x.toTracks(searchService)).find((x) => x !== null),
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { SearchHint as JellyfinSearchHint } from '@jellyfin/sdk/lib/generated-client/models';
|
import { SearchHint as JellyfinSearchHint } from '@jellyfin/sdk/lib/generated-client/models';
|
||||||
|
|
||||||
import { GenericTrack } from '../shared/GenericTrack';
|
import { Track } from '../shared/Track';
|
||||||
import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service';
|
import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service';
|
||||||
|
|
||||||
import { SearchHint } from './SearchHint';
|
import { SearchHint } from './SearchHint';
|
||||||
@ -20,7 +20,7 @@ export class PlaylistSearchHint extends SearchHint {
|
|||||||
|
|
||||||
override async toTracks(
|
override async toTracks(
|
||||||
searchService: JellyfinSearchService,
|
searchService: JellyfinSearchService,
|
||||||
): Promise<GenericTrack[]> {
|
): Promise<Track[]> {
|
||||||
const playlistItems = await searchService.getPlaylistitems(this.id);
|
const playlistItems = await searchService.getPlaylistitems(this.id);
|
||||||
const tracks = playlistItems.map(async (x) =>
|
const tracks = playlistItems.map(async (x) =>
|
||||||
(await x.toTracks(searchService)).find((x) => x !== null),
|
(await x.toTracks(searchService)).find((x) => x !== null),
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { SearchHint as JellyfinSearchHint } from '@jellyfin/sdk/lib/generated-client/models';
|
import { SearchHint as JellyfinSearchHint } from '@jellyfin/sdk/lib/generated-client/models';
|
||||||
|
|
||||||
import { GenericTrack } from '../shared/GenericTrack';
|
import { Track } from '../shared/Track';
|
||||||
import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service';
|
import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service';
|
||||||
|
|
||||||
export class SearchHint {
|
export class SearchHint {
|
||||||
@ -14,17 +14,10 @@ export class SearchHint {
|
|||||||
return `🎵 ${this.name}`;
|
return `🎵 ${this.name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async toTracks(
|
async toTracks(searchService: JellyfinSearchService): Promise<Track[]> {
|
||||||
searchService: JellyfinSearchService,
|
|
||||||
): Promise<GenericTrack[]> {
|
|
||||||
const remoteImages = await searchService.getRemoteImageById(this.id);
|
const remoteImages = await searchService.getRemoteImageById(this.id);
|
||||||
return [
|
return [
|
||||||
new GenericTrack(
|
new Track(this.id, this.name, this.runtimeInMilliseconds, remoteImages),
|
||||||
this.id,
|
|
||||||
this.name,
|
|
||||||
this.runtimeInMilliseconds,
|
|
||||||
remoteImages,
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
|
||||||
import { GenericTrack } from './GenericTrack';
|
import { Track } from './Track';
|
||||||
|
|
||||||
export class GenericPlaylist {
|
export class Playlist {
|
||||||
tracks: GenericTrack[];
|
tracks: Track[];
|
||||||
activeTrackIndex?: number;
|
activeTrackIndex?: number;
|
||||||
|
|
||||||
constructor(private readonly eventEmitter: EventEmitter2) {
|
constructor(private readonly eventEmitter: EventEmitter2) {
|
||||||
@ -23,7 +23,7 @@ export class GenericPlaylist {
|
|||||||
* Checks if the active track is out of bounds
|
* Checks if the active track is out of bounds
|
||||||
* @returns active track or undefined if there's none
|
* @returns active track or undefined if there's none
|
||||||
*/
|
*/
|
||||||
getActiveTrack(): GenericTrack | undefined {
|
getActiveTrack(): Track | undefined {
|
||||||
if (this.isActiveTrackOutOfSync()) {
|
if (this.isActiveTrackOutOfSync()) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -40,6 +40,10 @@ export class GenericPlaylist {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLength() {
|
||||||
|
return this.tracks.length;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Go to the next track in the playlist
|
* Go to the next track in the playlist
|
||||||
* @returns if the track has been changed successfully
|
* @returns if the track has been changed successfully
|
||||||
@ -79,13 +83,18 @@ export class GenericPlaylist {
|
|||||||
* @param tracks the tracks that should be added
|
* @param tracks the tracks that should be added
|
||||||
* @returns the new lendth of the tracks in the playlist
|
* @returns the new lendth of the tracks in the playlist
|
||||||
*/
|
*/
|
||||||
enqueueTracks(tracks: GenericTrack[]) {
|
enqueueTracks(tracks: Track[]) {
|
||||||
this.eventEmitter.emit('controls.playlist.tracks.enqueued', {
|
this.eventEmitter.emit('controls.playlist.tracks.enqueued', {
|
||||||
count: tracks.length,
|
count: tracks.length,
|
||||||
activeTrack: this.activeTrackIndex,
|
activeTrack: this.activeTrackIndex,
|
||||||
});
|
});
|
||||||
const length = this.tracks.push(...tracks);
|
const length = this.tracks.push(...tracks);
|
||||||
this.announceTrackChange();
|
|
||||||
|
// emit a track change if there is no item
|
||||||
|
if (!this.activeTrackIndex) {
|
||||||
|
this.announceTrackChange();
|
||||||
|
}
|
||||||
|
|
||||||
return length;
|
return length;
|
||||||
}
|
}
|
||||||
|
|
@ -1,8 +1,11 @@
|
|||||||
import { RemoteImageInfo, RemoteImageResult } from '@jellyfin/sdk/lib/generated-client/models';
|
import {
|
||||||
|
RemoteImageInfo,
|
||||||
|
RemoteImageResult,
|
||||||
|
} from '@jellyfin/sdk/lib/generated-client/models';
|
||||||
|
|
||||||
import { JellyfinStreamBuilderService } from '../../clients/jellyfin/jellyfin.stream.builder.service';
|
import { JellyfinStreamBuilderService } from '../../clients/jellyfin/jellyfin.stream.builder.service';
|
||||||
|
|
||||||
export class GenericTrack {
|
export class Track {
|
||||||
/**
|
/**
|
||||||
* The identifier of this track, structured as a UID.
|
* 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
|
* This id can be used to build a stream url and send more API requests to Jellyfin
|
||||||
@ -44,7 +47,7 @@ export class GenericTrack {
|
|||||||
return streamBuilder.buildStreamUrl(this.id, 96000);
|
return streamBuilder.buildStreamUrl(this.id, 96000);
|
||||||
}
|
}
|
||||||
|
|
||||||
getRemoteImage(): RemoteImageInfo | undefined {
|
getRemoteImages(): RemoteImageInfo[] {
|
||||||
return this.remoteImages.Images.find((x) => true);
|
return this.remoteImages.Images;
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,21 +1,21 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
|
||||||
import { GenericPlaylist } from '../models/shared/GenericPlaylist';
|
import { Playlist } from '../models/shared/Playlist';
|
||||||
|
|
||||||
@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 playlist: Playlist | undefined = undefined;
|
||||||
|
|
||||||
constructor(private readonly eventEmitter: EventEmitter2) {}
|
constructor(private readonly eventEmitter: EventEmitter2) {}
|
||||||
|
|
||||||
getPlaylistOrDefault(): GenericPlaylist {
|
getPlaylistOrDefault(): Playlist {
|
||||||
if (this.playlist) {
|
if (this.playlist) {
|
||||||
return this.playlist;
|
return this.playlist;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.playlist = new GenericPlaylist(this.eventEmitter);
|
this.playlist = new Playlist(this.eventEmitter);
|
||||||
return this.playlist;
|
return this.playlist;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
4
src/utils/arrayUtils.ts
Normal file
4
src/utils/arrayUtils.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export const chunkArray = <T>(a: T[], size): T[][] =>
|
||||||
|
Array.from(new Array(Math.ceil(a.length / size)), (_, i) =>
|
||||||
|
a.slice(i * size, i * size + size),
|
||||||
|
);
|
@ -1,11 +1,17 @@
|
|||||||
import { formatDuration, intervalToDuration } from 'date-fns';
|
import { formatDuration, intervalToDuration } from 'date-fns';
|
||||||
|
|
||||||
export const formatMillisecondsAsHumanReadable = (milliseconds: number) => {
|
export const formatMillisecondsAsHumanReadable = (
|
||||||
|
milliseconds: number,
|
||||||
|
format = ['years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds'],
|
||||||
|
) => {
|
||||||
const duration = formatDuration(
|
const duration = formatDuration(
|
||||||
intervalToDuration({
|
intervalToDuration({
|
||||||
start: milliseconds,
|
start: milliseconds,
|
||||||
end: 0,
|
end: 0,
|
||||||
}),
|
}),
|
||||||
|
{
|
||||||
|
format: format,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return duration;
|
return duration;
|
||||||
};
|
};
|
||||||
|
16
yarn.lock
16
yarn.lock
@ -359,19 +359,19 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@jridgewell/trace-mapping" "0.3.9"
|
"@jridgewell/trace-mapping" "0.3.9"
|
||||||
|
|
||||||
"@discord-nestjs/common@^5.2.0":
|
"@discord-nestjs/common@^5.2.1":
|
||||||
version "5.2.0"
|
version "5.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/@discord-nestjs/common/-/common-5.2.0.tgz#3bdf25eadf8372d81110e2aeefbb31e707e75554"
|
resolved "https://registry.yarnpkg.com/@discord-nestjs/common/-/common-5.2.1.tgz#113a6a67481c9bb5d2e7a0ee76ee61dea555c489"
|
||||||
integrity sha512-aXp6P7XyDk/Zoz9zpe5DLqGFBfZrz1fu6Vc8oMz2RggVxBm8k8P5bH5iOcIvI0jWsjbZ3pVCVB3SmCpjBFItRA==
|
integrity sha512-6JP53oA6Fysh1Xj3i30zaJTQIZWoPiigqbHjjzPFOMUSjKbaIEX0/75gZm0JBHCPw9oUnVBGq8taV200pyiosg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@nestjs/mapped-types" "1.2.2"
|
"@nestjs/mapped-types" "1.2.2"
|
||||||
class-transformer "0.5.1"
|
class-transformer "0.5.1"
|
||||||
class-validator "0.14.0"
|
class-validator "0.14.0"
|
||||||
|
|
||||||
"@discord-nestjs/core@^5.3.0":
|
"@discord-nestjs/core@^5.3.3":
|
||||||
version "5.3.0"
|
version "5.3.3"
|
||||||
resolved "https://registry.yarnpkg.com/@discord-nestjs/core/-/core-5.3.0.tgz#8e93b0310e8cc2c0cde74c6317d949d6e9d28d2d"
|
resolved "https://registry.yarnpkg.com/@discord-nestjs/core/-/core-5.3.3.tgz#0e0af8cfc7b1c6df0dd9668573a51b44c3940033"
|
||||||
integrity sha512-eHVzuPCu3EbQyTln+ZEH0/Jwe0xPG7Z1eZV655jZoCSpq7RmvxWcTG/REf3XMSgtYFN27HaXa9vPCsgUOnp9xQ==
|
integrity sha512-R3duQIUU9qQiKEIyleG2swdDdGp3FXaXHbgooVieyEJVx8tvIulH5BryE0lnYiUdPEvXbZrBF+w476PATiDWMQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
class-transformer "0.5.1"
|
class-transformer "0.5.1"
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user