Add paged playlist (#106)

This commit is contained in:
Manuel 2023-03-07 21:22:19 +01:00 committed by GitHub
parent 12065e6c90
commit 916969f07b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 353 additions and 144 deletions

View File

@ -21,8 +21,8 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@discord-nestjs/common": "^5.2.0",
"@discord-nestjs/core": "^5.3.0",
"@discord-nestjs/common": "^5.2.1",
"@discord-nestjs/core": "^5.3.3",
"@discordjs/opus": "^0.9.0",
"@discordjs/voice": "^0.14.0",
"@jellyfin/sdk": "^0.7.0",

View File

@ -24,6 +24,9 @@ import { JellyfinClientModule } from './clients/jellyfin/jellyfin.module';
JELLYFIN_AUTHENTICATION_USERNAME: Joi.string().required(),
JELLYFIN_AUTHENTICATION_PASSWORD: Joi.string().required(),
UPDATER_DISABLE_NOTIFICATIONS: Joi.boolean(),
LOG_LEVEL: Joi.string()
.valid('error', 'warn', 'log', 'debug', 'verbose')
.default('log'),
PORT: Joi.number().min(1),
}),
}),

View File

@ -22,7 +22,7 @@ import { JellyfinStreamBuilderService } from '../jellyfin/jellyfin.stream.builde
import { JellyfinWebSocketService } from '../jellyfin/jellyfin.websocket.service';
import { GenericTryHandler } from '../../models/generic-try-handler';
import { PlaybackService } from '../../playback/playback.service';
import { GenericTrack } from '../../models/shared/GenericTrack';
import { Track } from '../../models/shared/Track';
import { DiscordMessageService } from './discord.message.service';
@ -41,7 +41,7 @@ export class DiscordVoiceService {
) {}
@OnEvent('internal.audio.announce')
handleOnNewTrack(track: GenericTrack) {
handleOnNewTrack(track: Track) {
const resource = createAudioResource(
track.getStreamUrl(this.jellyfinStreamBuilder),
);
@ -69,7 +69,7 @@ export class DiscordVoiceService {
success: false,
reply: {
embeds: [
this.discordMessageService.buildErrorMessage({
this.discordMessageService.buildMessage({
title: 'Unable to join your channel',
description:
"I am unable to join your channel, because you don't seem to be in a voice channel. Connect to a channel first to use this command",

View File

@ -134,6 +134,10 @@ export class JellyfinSearchService {
const api = this.jellyfinService.getApi();
const remoteImageApi = getRemoteImageApi(api);
this.logger.verbose(
`Searching for remote images of item '${id}' with limit of ${limit}`,
);
try {
const axiosReponse = await remoteImageApi.getRemoteImages({
itemId: id,
@ -151,6 +155,10 @@ export class JellyfinSearchService {
TotalRecordCount: 0,
};
}
this.logger.verbose(
`Retrieved ${axiosReponse.data.TotalRecordCount} remote images from Jellyfin`,
);
return axiosReponse.data;
} catch (err) {
this.logger.error(`Failed to retrieve remote images: ${err}`);

View File

@ -14,7 +14,7 @@ import {
PlayNowCommand,
SessionApiSendPlaystateCommandRequest,
} from '../../types/websocket';
import { GenericTrack } from '../../models/shared/GenericTrack';
import { Track } from '../../models/shared/Track';
import { JellyfinSearchService } from './jellyfin.search.service';
import { JellyfinService } from './jellyfin.service';

View File

@ -4,7 +4,7 @@ import { Module } from '@nestjs/common';
import { DiscordClientModule } from '../clients/discord/discord.module';
import { JellyfinClientModule } from '../clients/jellyfin/jellyfin.module';
import { PlaybackModule } from '../playback/playback.module';
import { PlaylistCommand } from './playlist.command';
import { PlaylistCommand } from './playlist/playlist.command';
import { DisconnectCommand } from './disconnect.command';
import { HelpCommand } from './help.command';
import { PausePlaybackCommand } from './pause.command';
@ -14,6 +14,7 @@ import { SkipTrackCommand } from './next.command';
import { StatusCommand } from './status.command';
import { StopPlaybackCommand } from './stop.command';
import { SummonCommand } from './summon.command';
import { PlaylistInteractionCollector } from './playlist/playlist.interaction-collector';
@Module({
imports: [
@ -24,6 +25,7 @@ import { SummonCommand } from './summon.command';
],
controllers: [],
providers: [
PlaylistInteractionCollector,
HelpCommand,
StatusCommand,
PlaylistCommand,

View File

@ -48,7 +48,7 @@ export class PlayItemCommand {
@InteractionEvent(SlashCommandPipe) dto: TrackRequestDto,
@IA() interaction: CommandInteraction,
): Promise<InteractionReplyOptions | string> {
await interaction.deferReply();
await interaction.deferReply({ ephemeral: true });
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`,
}),
],
ephemeral: true,
});
return;
}
@ -96,20 +97,18 @@ export class PlayItemCommand {
(sum, item) => sum + item.duration,
0,
);
const enqueuedCount = this.playbackService
.getPlaylistOrDefault()
.enqueueTracks(tracks);
console.log(tracks);
this.playbackService.getPlaylistOrDefault().enqueueTracks(tracks);
const remoteImage: RemoteImageInfo | undefined = tracks
.map((x) => x.getRemoteImage())
.flatMap((x) => x.getRemoteImages())
.find((x) => true);
await interaction.followUp({
embeds: [
this.discordMessageService.buildMessage({
title: `Added ${enqueuedCount} tracks to your playlist (${formatMillisecondsAsHumanReadable(
title: `Added ${this.playbackService
.getPlaylistOrDefault()
.getLength()} tracks to your playlist (${formatMillisecondsAsHumanReadable(
reducedDuration,
)})`,
mixin(embedBuilder) {
@ -120,6 +119,7 @@ export class PlayItemCommand {
},
}),
],
ephemeral: true,
});
}

View File

@ -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}. `;
}
}

View 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}. `;
}
}

View 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;
}
}
}

View File

@ -4,11 +4,21 @@ import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
function getLoggingLevels(): LogLevel[] {
if (process.env.DEBUG) {
return ['log', 'error', 'warn', 'debug'];
switch (process.env.LOG_LEVEL.toLowerCase()) {
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() {

View File

@ -1,6 +1,6 @@
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 { SearchHint } from './SearchHint';
@ -16,7 +16,7 @@ export class AlbumSearchHint extends SearchHint {
override async toTracks(
searchService: JellyfinSearchService,
): Promise<GenericTrack[]> {
): Promise<Track[]> {
const albumItems = await searchService.getAlbumItems(this.id);
const tracks = albumItems.map(async (x) =>
(await x.toTracks(searchService)).find((x) => x !== null),

View File

@ -1,6 +1,6 @@
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 { SearchHint } from './SearchHint';
@ -20,7 +20,7 @@ export class PlaylistSearchHint extends SearchHint {
override async toTracks(
searchService: JellyfinSearchService,
): Promise<GenericTrack[]> {
): Promise<Track[]> {
const playlistItems = await searchService.getPlaylistitems(this.id);
const tracks = playlistItems.map(async (x) =>
(await x.toTracks(searchService)).find((x) => x !== null),

View File

@ -1,6 +1,6 @@
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';
export class SearchHint {
@ -14,17 +14,10 @@ export class SearchHint {
return `🎵 ${this.name}`;
}
async toTracks(
searchService: JellyfinSearchService,
): Promise<GenericTrack[]> {
async toTracks(searchService: JellyfinSearchService): Promise<Track[]> {
const remoteImages = await searchService.getRemoteImageById(this.id);
return [
new GenericTrack(
this.id,
this.name,
this.runtimeInMilliseconds,
remoteImages,
),
new Track(this.id, this.name, this.runtimeInMilliseconds, remoteImages),
];
}

View File

@ -1,9 +1,9 @@
import { EventEmitter2 } from '@nestjs/event-emitter';
import { GenericTrack } from './GenericTrack';
import { Track } from './Track';
export class GenericPlaylist {
tracks: GenericTrack[];
export class Playlist {
tracks: Track[];
activeTrackIndex?: number;
constructor(private readonly eventEmitter: EventEmitter2) {
@ -23,7 +23,7 @@ export class GenericPlaylist {
* Checks if the active track is out of bounds
* @returns active track or undefined if there's none
*/
getActiveTrack(): GenericTrack | undefined {
getActiveTrack(): Track | undefined {
if (this.isActiveTrackOutOfSync()) {
return undefined;
}
@ -40,6 +40,10 @@ export class GenericPlaylist {
);
}
getLength() {
return this.tracks.length;
}
/**
* Go to the next track in the playlist
* @returns if the track has been changed successfully
@ -79,13 +83,18 @@ export class GenericPlaylist {
* @param tracks the tracks that should be added
* @returns the new lendth of the tracks in the playlist
*/
enqueueTracks(tracks: GenericTrack[]) {
enqueueTracks(tracks: Track[]) {
this.eventEmitter.emit('controls.playlist.tracks.enqueued', {
count: tracks.length,
activeTrack: this.activeTrackIndex,
});
const length = this.tracks.push(...tracks);
this.announceTrackChange();
// emit a track change if there is no item
if (!this.activeTrackIndex) {
this.announceTrackChange();
}
return length;
}

View File

@ -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';
export class GenericTrack {
export class Track {
/**
* 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
@ -44,7 +47,7 @@ export class GenericTrack {
return streamBuilder.buildStreamUrl(this.id, 96000);
}
getRemoteImage(): RemoteImageInfo | undefined {
return this.remoteImages.Images.find((x) => true);
getRemoteImages(): RemoteImageInfo[] {
return this.remoteImages.Images;
}
}

View File

@ -1,21 +1,21 @@
import { Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { GenericPlaylist } from '../models/shared/GenericPlaylist';
import { Playlist } from '../models/shared/Playlist';
@Injectable()
export class PlaybackService {
private readonly logger = new Logger(PlaybackService.name);
private playlist: GenericPlaylist | undefined = undefined;
private playlist: Playlist | undefined = undefined;
constructor(private readonly eventEmitter: EventEmitter2) {}
getPlaylistOrDefault(): GenericPlaylist {
getPlaylistOrDefault(): Playlist {
if (this.playlist) {
return this.playlist;
}
this.playlist = new GenericPlaylist(this.eventEmitter);
this.playlist = new Playlist(this.eventEmitter);
return this.playlist;
}
}

4
src/utils/arrayUtils.ts Normal file
View 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),
);

View File

@ -1,11 +1,17 @@
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(
intervalToDuration({
start: milliseconds,
end: 0,
}),
{
format: format,
},
);
return duration;
};

View File

@ -359,19 +359,19 @@
dependencies:
"@jridgewell/trace-mapping" "0.3.9"
"@discord-nestjs/common@^5.2.0":
version "5.2.0"
resolved "https://registry.yarnpkg.com/@discord-nestjs/common/-/common-5.2.0.tgz#3bdf25eadf8372d81110e2aeefbb31e707e75554"
integrity sha512-aXp6P7XyDk/Zoz9zpe5DLqGFBfZrz1fu6Vc8oMz2RggVxBm8k8P5bH5iOcIvI0jWsjbZ3pVCVB3SmCpjBFItRA==
"@discord-nestjs/common@^5.2.1":
version "5.2.1"
resolved "https://registry.yarnpkg.com/@discord-nestjs/common/-/common-5.2.1.tgz#113a6a67481c9bb5d2e7a0ee76ee61dea555c489"
integrity sha512-6JP53oA6Fysh1Xj3i30zaJTQIZWoPiigqbHjjzPFOMUSjKbaIEX0/75gZm0JBHCPw9oUnVBGq8taV200pyiosg==
dependencies:
"@nestjs/mapped-types" "1.2.2"
class-transformer "0.5.1"
class-validator "0.14.0"
"@discord-nestjs/core@^5.3.0":
version "5.3.0"
resolved "https://registry.yarnpkg.com/@discord-nestjs/core/-/core-5.3.0.tgz#8e93b0310e8cc2c0cde74c6317d949d6e9d28d2d"
integrity sha512-eHVzuPCu3EbQyTln+ZEH0/Jwe0xPG7Z1eZV655jZoCSpq7RmvxWcTG/REf3XMSgtYFN27HaXa9vPCsgUOnp9xQ==
"@discord-nestjs/core@^5.3.3":
version "5.3.3"
resolved "https://registry.yarnpkg.com/@discord-nestjs/core/-/core-5.3.3.tgz#0e0af8cfc7b1c6df0dd9668573a51b44c3940033"
integrity sha512-R3duQIUU9qQiKEIyleG2swdDdGp3FXaXHbgooVieyEJVx8tvIulH5BryE0lnYiUdPEvXbZrBF+w476PATiDWMQ==
dependencies:
class-transformer "0.5.1"