🔀 Version 0.0.5

This commit is contained in:
Manuel 2023-03-14 21:20:56 +01:00 committed by GitHub
commit 8d53414e3d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 6590 additions and 18130 deletions

View File

@ -23,7 +23,7 @@ jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
packages: writement work
packages: write
contents: read
steps:
- name: Setup
@ -41,6 +41,7 @@ jobs:
restore-keys: |
${{ runner.os }}-yarn-
- run: yarn install --immutable
- run: yarn test
- run: yarn build
- name: Docker meta
id: meta

View File

@ -41,8 +41,8 @@ jobs:
restore-keys: |
${{ runner.os }}-yarn-
- run: yarn install --immutable
- run: yarn build
- run: yarn test
- run: yarn build
- name: Docker meta
id: meta
uses: docker/metadata-action@v4

View File

@ -6,7 +6,4 @@ WORKDIR /app
EXPOSE 3000
RUN ls -lha
RUN ls dist -lha
CMD ["yarn", "start:prod"]

View File

@ -12,9 +12,12 @@
</p>
<p align="center">
<a href="https://github.com/manuel-rw/jellyfin-discord-music-bot/wiki/%F0%9F%9A%80-Installation"><img src="https://img.shields.io/badge/-Installation%20Guide-7289da?style=for-the-badge&logo=markdown" alt="badge" /></a><br/><br/>
<a href="https://github.com/manuel-rw/jellyfin-discord-music-bot/wiki/%F0%9F%9A%80-Installation"><img src="https://img.shields.io/badge/-Installation%20Guide-7289da?style=for-the-badge&logo=markdown" alt="badge" /></a>
<a href="https://discord.gg/hRHZ3q3VDX"><img src="https://img.shields.io/badge/-Community%20Discord-7289da?style=for-the-badge&logo=discord" alt="badge" /></a>
<a href='https://ko-fi.com/A0A42YZ7W' target='_blank'><img src="https://img.shields.io/badge/-Buy%20me%20a%20coffee-f1f1f1?style=for-the-badge&logo=kofi" alt="badge" /></a>
<br/>
<br/>
<img src="https://github.com/manuel-rw/jellyfin-discord-music-bot/actions/workflows/docker.yml/badge.svg?branch=master" />
</p>
<br/>

View File

52
client/index.html Normal file
View File

@ -0,0 +1,52 @@
<!DOCTYPE html>
<html class="h-full bg-gray-100 dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="assets/css/index.css" rel="stylesheet" />
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
clifford: '#da373d',
},
},
},
};
</script>
<title>🎉 Jellyfin Discord Bot</title>
</head>
<body class="h-full">
<div class="min-h-full bg-zinc-900">
<nav class="bg-zinc-800">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0">
<img
class="h-8 w-8"
src="https://raw.githubusercontent.com/manuel-rw/jellyfin-discord-music-bot/master/images/icons/jellyfin-icon-squared.png"
alt="Jellyfin logo"
/>
</div>
<span class="ml-5 text-gray-300 text-lg">Discord Bot</span>
</div>
</div>
</div>
</nav>
<div class="mx-auto max-w-7xl py-6 px-4 sm:px-6 lg:px-8">
<h1 class="text-3xl font-bold tracking-tight text-gray-100 text-center">
Jellyfin Discord Bot
</h1>
<h3 class="text-2xl text-gray-300 text-center">
Congratulations, your bot is up and running!
</h3>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{html,js}"],
theme: {
extend: {},
},
plugins: [],
}

9311
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "jellyfin-discord-music-bot",
"version": "0.0.4",
"version": "0.0.5",
"description": "",
"author": "manuel-rw",
"private": true,
@ -21,10 +21,10 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@discord-nestjs/common": "^4.0.8",
"@discord-nestjs/core": "^4.3.1",
"@discord-nestjs/common": "^5.2.2",
"@discord-nestjs/core": "^5.3.4",
"@discordjs/opus": "^0.9.0",
"@discordjs/voice": "^0.14.0",
"@discordjs/voice": "^0.15.0",
"@jellyfin/sdk": "^0.7.0",
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.2.0",
@ -32,29 +32,30 @@
"@nestjs/event-emitter": "^1.3.1",
"@nestjs/platform-express": "^9.0.0",
"@nestjs/schedule": "^2.1.0",
"@nestjs/serve-static": "^3.0.1",
"@nestjs/terminus": "^9.1.4",
"date-fns": "^2.29.3",
"discord.js": "^14.7.1",
"joi": "^17.7.0",
"discord.js": "^14.8.0",
"joi": "^17.8.4",
"libsodium-wrappers": "^0.7.10",
"reflect-metadata": "^0.1.13",
"rimraf": "^4.1.2",
"rimraf": "^4.4.0",
"rxjs": "^7.2.0",
"uuid": "^9.0.0",
"ws": "^8.11.0"
"ws": "^8.13.0"
},
"devDependencies": {
"@nestjs/cli": "^9.1.8",
"@nestjs/cli": "^9.2.0",
"@nestjs/schematics": "^9.0.0",
"@nestjs/testing": "^9.0.0",
"@nestjs/testing": "^9.3.9",
"@types/cron": "^2.0.0",
"@types/express": "^4.17.13",
"@types/jest": "28.1.8",
"@types/node": "^18.11.18",
"@types/node": "^18.15.0",
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.52.0",
"eslint": "^8.32.0",
"@typescript-eslint/eslint-plugin": "^5.53.0",
"@typescript-eslint/parser": "^5.55.0",
"eslint": "^8.35.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-prettier": "^4.0.0",
"jest": "28.1.3",

View File

@ -1,11 +1,14 @@
import { Module } from '@nestjs/common';
import * as Joi from 'joi';
import { DiscordModule } from '@discord-nestjs/core';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule';
import * as Joi from 'joi';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import { DiscordConfigService } from './clients/discord/discord.config.service';
import { DiscordClientModule } from './clients/discord/discord.module';
import { JellyfinClientModule } from './clients/jellyfin/jellyfin.module';
@ -23,9 +26,15 @@ import { UpdatesModule } from './updates/updates.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),
}),
}),
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'client'),
}),
ScheduleModule.forRoot(),
DiscordModule.forRootAsync({
useClass: DiscordConfigService,

View File

@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common';
import { APIEmbed, EmbedBuilder } from 'discord.js';
import { DefaultJellyfinColor, ErrorJellyfinColor } from '../../types/colors';
import { formatRFC7231 } from 'date-fns';
import { APIEmbed, EmbedBuilder } from 'discord.js';
import { DefaultJellyfinColor, ErrorJellyfinColor } from '../../types/colors';
import { Constants } from '../../utils/constants';
@Injectable()
@ -14,7 +14,6 @@ export class DiscordMessageService {
title: string;
description?: string;
}): APIEmbed {
const date = formatRFC7231(new Date());
return this.buildMessage({
title: title,
description: description,
@ -25,7 +24,7 @@ export class DiscordMessageService {
iconURL: Constants.Design.Icons.ErrorIcon,
})
.setFooter({
text: `${date} - Report an issue: ${Constants.Links.ReportIssue}`,
text: `Report this issue: ${Constants.Links.ReportIssue}`,
})
.setColor(ErrorJellyfinColor);
},
@ -43,17 +42,12 @@ export class DiscordMessageService {
authorUrl?: string;
mixin?: (embedBuilder: EmbedBuilder) => EmbedBuilder;
}): APIEmbed {
const date = formatRFC7231(new Date());
let embedBuilder = new EmbedBuilder()
.setColor(DefaultJellyfinColor)
.setAuthor({
name: title,
iconURL: Constants.Design.Icons.JellyfinLogo,
url: authorUrl,
})
.setFooter({
text: `${date}`,
});
if (description !== undefined && description.length >= 1) {

View File

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

View File

@ -7,16 +7,23 @@ import {
getVoiceConnection,
getVoiceConnections,
joinVoiceChannel,
NoSubscriberBehavior,
VoiceConnection,
VoiceConnectionStatus,
} from '@discordjs/voice';
import { Injectable } from '@nestjs/common';
import { Logger } from '@nestjs/common/services';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import { GuildMember } from 'discord.js';
import { JellyfinStreamBuilderService } from '../jellyfin/jellyfin.stream.builder.service';
import { JellyfinWebSocketService } from '../jellyfin/jellyfin.websocket.service';
import { GenericTryHandler } from '../../models/generic-try-handler';
import { PlaybackService } from '../../playback/playback.service';
import { Track } from '../../types/track';
import { JellyfinWebSocketService } from '../jellyfin/jellyfin.websocket.service';
import { Track } from '../../models/shared/Track';
import { DiscordMessageService } from './discord.message.service';
@Injectable()
@ -29,12 +36,15 @@ export class DiscordVoiceService {
private readonly discordMessageService: DiscordMessageService,
private readonly playbackService: PlaybackService,
private readonly jellyfinWebSocketService: JellyfinWebSocketService,
private readonly jellyfinStreamBuilder: JellyfinStreamBuilderService,
private readonly eventEmitter: EventEmitter2,
) {}
@OnEvent('playback.newTrack')
handleOnNewTrack(newTrack: Track) {
const resource = createAudioResource(newTrack.streamUrl);
@OnEvent('internal.audio.announce')
handleOnNewTrack(track: Track) {
const resource = createAudioResource(
track.getStreamUrl(this.jellyfinStreamBuilder),
);
this.playResource(resource);
}
@ -59,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",
@ -90,13 +100,13 @@ export class DiscordVoiceService {
}
playResource(resource: AudioResource<unknown>) {
this.logger.debug(`Playing audio resource with volume ${resource.volume}`);
this.createAndReturnOrGetAudioPlayer().play(resource);
}
/**
* Pauses the current audio player
*/
@OnEvent('playback.control.pause')
pause() {
this.createAndReturnOrGetAudioPlayer().pause();
this.eventEmitter.emit('playback.state.pause', true);
@ -105,7 +115,6 @@ export class DiscordVoiceService {
/**
* Stops the audio player
*/
@OnEvent('playback.control.stop')
stop(force: boolean): boolean {
const stopped = this.createAndReturnOrGetAudioPlayer().stop(force);
this.eventEmitter.emit('playback.state.stop');
@ -143,7 +152,6 @@ export class DiscordVoiceService {
* Checks if the current state is paused or not and toggles the states to the opposite.
* @returns The new paused state - true: paused, false: unpaused
*/
@OnEvent('playback.control.togglePause')
togglePaused(): boolean {
if (this.isPaused()) {
this.unpause();
@ -191,11 +199,22 @@ export class DiscordVoiceService {
}
private createAndReturnOrGetAudioPlayer() {
if (this.voiceConnection === undefined) {
throw new Error(
'Voice connection has not been initialized and audio player can\t be created',
);
}
if (this.audioPlayer === undefined) {
this.logger.debug(
`Initialized new instance of Audio Player because it has not been defined yet`,
`Initialized new instance of AudioPlayer because it has not been defined yet`,
);
this.audioPlayer = createAudioPlayer();
this.audioPlayer = createAudioPlayer({
debug: process.env.DEBUG?.toLowerCase() === 'true',
behaviors: {
noSubscriber: NoSubscriberBehavior.Play,
},
});
this.attachEventListenersToAudioPlayer();
this.voiceConnection.subscribe(this.audioPlayer);
return this.audioPlayer;
@ -205,6 +224,16 @@ export class DiscordVoiceService {
}
private attachEventListenersToAudioPlayer() {
this.voiceConnection.on('debug', (message) => {
if (process.env.DEBUG?.toLowerCase() !== 'true') {
return;
}
this.logger.debug(message);
});
this.voiceConnection.on('error', (err) => {
this.logger.error(`Voice connection error: ${err}`);
});
this.audioPlayer.on('debug', (message) => {
this.logger.debug(message);
});
@ -212,6 +241,10 @@ export class DiscordVoiceService {
this.logger.error(message);
});
this.audioPlayer.on('stateChange', (previousState) => {
this.logger.debug(
`Audio player changed state from ${previousState.status} to ${this.audioPlayer.state.status}`,
);
if (previousState.status !== AudioPlayerStatus.Playing) {
return;
}
@ -220,20 +253,22 @@ export class DiscordVoiceService {
return;
}
const hasNextTrack = this.playbackService.hasNextTrack();
this.logger.debug(`Audio player finished playing old resource`);
const hasNextTrack = this.playbackService
.getPlaylistOrDefault()
.hasNextTrackInPlaylist();
this.logger.debug(
`Deteced audio player status change from ${previousState.status} to ${
this.audioPlayer.state.status
}. Has next track: ${hasNextTrack ? 'yes' : 'no'}`,
`Playlist has next track: ${hasNextTrack ? 'yes' : 'no'}`,
);
if (!hasNextTrack) {
this.logger.debug(`Audio Player has reached the end of the playlist`);
this.logger.debug(`Reached the end of the playlist`);
return;
}
this.playbackService.nextTrack();
this.playbackService.getPlaylistOrDefault().setNextTrackAsActiveTrack();
});
}
}

View File

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

View File

@ -1,20 +1,21 @@
import { Injectable } from '@nestjs/common';
import { JellyfinService } from './jellyfin.service';
import {
BaseItemKind,
RemoteImageResult,
SearchHint,
SearchHint as JellyfinSearchHint,
} from '@jellyfin/sdk/lib/generated-client/models';
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api';
import { getPlaylistsApi } from '@jellyfin/sdk/lib/utils/api/playlists-api';
import { getRemoteImageApi } from '@jellyfin/sdk/lib/utils/api/remote-image-api';
import { getSearchApi } from '@jellyfin/sdk/lib/utils/api/search-api';
import { Injectable } from '@nestjs/common';
import { Logger } from '@nestjs/common/services';
import {
JellyfinAudioPlaylist,
JellyfinMusicAlbum,
} from '../../models/jellyfinAudioItems';
import { AlbumSearchHint } from '../../models/search/AlbumSearchHint';
import { PlaylistSearchHint } from '../../models/search/PlaylistSearchHint';
import { SearchHint } from '../../models/search/SearchHint';
import { JellyfinService } from './jellyfin.service';
@Injectable()
export class JellyfinSearchService {
@ -22,35 +23,50 @@ export class JellyfinSearchService {
constructor(private readonly jellyfinService: JellyfinService) {}
async search(searchTerm: string): Promise<SearchHint[]> {
async searchItem(
searchTerm: string,
limit?: number,
includeItemTypes: BaseItemKind[] = [
BaseItemKind.Audio,
BaseItemKind.MusicAlbum,
BaseItemKind.Playlist,
],
): Promise<SearchHint[]> {
const api = this.jellyfinService.getApi();
this.logger.debug(`Searching for '${searchTerm}'`);
const searchApi = getSearchApi(api);
const {
data: { SearchHints, TotalRecordCount },
status,
} = await searchApi.get({
searchTerm: searchTerm,
includeItemTypes: [
BaseItemKind.Audio,
BaseItemKind.MusicAlbum,
BaseItemKind.Playlist,
],
});
if (status !== 200) {
this.logger.error(`Jellyfin Search failed with status code ${status}`);
return [];
if (includeItemTypes.length === 0) {
this.logger.warn(
`Included item types are empty. This may lead to unwanted results`,
);
}
this.logger.debug(`Found ${TotalRecordCount} results for '${searchTerm}'`);
try {
const { data, status } = await searchApi.get({
searchTerm: searchTerm,
includeItemTypes: includeItemTypes,
limit: limit,
});
return SearchHints;
if (status !== 200) {
this.logger.error(
`Jellyfin Search failed with status code ${status}: ${data}`,
);
return [];
}
const { SearchHints } = data;
return SearchHints.map((hint) => this.transformToSearchHint(hint)).filter(
(x) => x !== null,
);
} catch (err) {
this.logger.error(`Failed to search on Jellyfin: ${err}`);
return [];
}
}
async getPlaylistById(id: string): Promise<JellyfinAudioPlaylist> {
async getPlaylistitems(id: string): Promise<SearchHint[]> {
const api = this.jellyfinService.getApi();
const searchApi = getPlaylistsApi(api);
@ -63,13 +79,15 @@ export class JellyfinSearchService {
this.logger.error(
`Jellyfin Search failed with status code ${axiosResponse.status}`,
);
return new JellyfinAudioPlaylist();
return [];
}
return axiosResponse.data as JellyfinAudioPlaylist;
return axiosResponse.data.Items.map((hint) =>
SearchHint.constructFromHint(hint),
);
}
async getItemsByAlbum(albumId: string): Promise<JellyfinMusicAlbum> {
async getAlbumItems(albumId: string): Promise<SearchHint[]> {
const api = this.jellyfinService.getApi();
const searchApi = getSearchApi(api);
const axiosResponse = await searchApi.get({
@ -83,19 +101,25 @@ export class JellyfinSearchService {
this.logger.error(
`Jellyfin Search failed with status code ${axiosResponse.status}`,
);
return new JellyfinMusicAlbum();
return [];
}
return axiosResponse.data as JellyfinMusicAlbum;
return axiosResponse.data.SearchHints.map((hint) =>
SearchHint.constructFromHint(hint),
);
}
async getById(id: string): Promise<SearchHint> {
async getById(
id: string,
includeItemTypes: BaseItemKind[],
): Promise<SearchHint> | undefined {
const api = this.jellyfinService.getApi();
const searchApi = getItemsApi(api);
const { data } = await searchApi.getItems({
ids: [id],
userId: this.jellyfinService.getUserId(),
includeItemTypes: includeItemTypes,
});
if (data.Items.length !== 1) {
@ -103,30 +127,62 @@ export class JellyfinSearchService {
return null;
}
return data.Items[0];
return this.transformToSearchHint(data.Items[0]);
}
async getRemoteImageById(id: string): Promise<RemoteImageResult> {
async getRemoteImageById(id: string, limit = 20): Promise<RemoteImageResult> {
const api = this.jellyfinService.getApi();
const remoteImageApi = getRemoteImageApi(api);
const axiosReponse = await remoteImageApi.getRemoteImages({
itemId: id,
includeAllLanguages: true,
limit: 20,
});
this.logger.verbose(
`Searching for remote images of item '${id}' with limit of ${limit}`,
);
if (axiosReponse.status !== 200) {
this.logger.warn(
`Failed to retrieve remote images. Response has status ${axiosReponse.status}`,
try {
const axiosReponse = await remoteImageApi.getRemoteImages({
itemId: id,
includeAllLanguages: true,
limit: limit,
});
if (axiosReponse.status !== 200) {
this.logger.warn(
`Failed to retrieve remote images. Response has status ${axiosReponse.status}`,
);
return {
Images: [],
Providers: [],
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}`);
return {
Images: [],
Providers: [],
TotalRecordCount: 0,
};
}
}
return axiosReponse.data;
private transformToSearchHint(jellyifnHint: JellyfinSearchHint) {
switch (jellyifnHint.Type) {
case BaseItemKind[BaseItemKind.Audio]:
return SearchHint.constructFromHint(jellyifnHint);
case BaseItemKind[BaseItemKind.MusicAlbum]:
return AlbumSearchHint.constructFromHint(jellyifnHint);
case BaseItemKind[BaseItemKind.Playlist]:
return PlaylistSearchHint.constructFromHint(jellyifnHint);
default:
this.logger.warn(
`Received unexpected item type from Jellyfin search: ${jellyifnHint.Type}`,
);
return null;
}
}
}

View File

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

View File

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

View File

@ -4,16 +4,17 @@ 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';
import { PlayItemCommand } from './play.comands';
import { PlayItemCommand } from './play/play.comands';
import { PreviousTrackCommand } from './previous.command';
import { SkipTrackCommand } from './next.command';
import { StatusCommand } from './status.command';
import { StopPlaybackCommand } from './stop.command';
import { SummonCommand } from './summon.command';
import { 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

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

View File

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

View File

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

View File

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

View File

@ -1,319 +0,0 @@
import { TransformPipe } from '@discord-nestjs/common';
import {
Command,
DiscordTransformedCommand,
On,
Payload,
TransformedCommandExecutionContext,
UsePipes,
} from '@discord-nestjs/core';
import { Logger } from '@nestjs/common/services';
import {
ComponentType,
Events,
GuildMember,
Interaction,
InteractionReplyOptions,
} from 'discord.js';
import { JellyfinSearchService } from '../clients/jellyfin/jellyfin.search.service';
import { TrackRequestDto } from '../models/track-request.dto';
import { DiscordMessageService } from '../clients/discord/discord.message.service';
import { RemoteImageResult } from '@jellyfin/sdk/lib/generated-client/models';
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
import { JellyfinStreamBuilderService } from '../clients/jellyfin/jellyfin.stream.builder.service';
import {
BaseJellyfinAudioPlayable,
searchResultAsJellyfinAudio,
} from '../models/jellyfinAudioItems';
import { PlaybackService } from '../playback/playback.service';
import { chooseSuitableRemoteImage } from '../utils/remoteImages/remoteImages';
import { trimStringToFixedLength } from '../utils/stringUtils/stringUtils';
@Command({
name: 'play',
description: 'Search for an item on your Jellyfin instance',
})
@UsePipes(TransformPipe)
export class PlayItemCommand
implements DiscordTransformedCommand<TrackRequestDto>
{
private readonly logger: Logger = new Logger(PlayItemCommand.name);
constructor(
private readonly jellyfinSearchService: JellyfinSearchService,
private readonly discordMessageService: DiscordMessageService,
private readonly discordVoiceService: DiscordVoiceService,
private readonly playbackService: PlaybackService,
private readonly jellyfinStreamBuilder: JellyfinStreamBuilderService,
) {}
async handler(
@Payload() dto: TrackRequestDto,
executionContext: TransformedCommandExecutionContext<any>,
): Promise<InteractionReplyOptions | string> {
await executionContext.interaction.deferReply();
const items = await this.jellyfinSearchService.search(dto.search);
const parsedItems = await Promise.all(
items.map(
async (item) =>
await searchResultAsJellyfinAudio(
this.logger,
this.jellyfinSearchService,
item,
),
),
);
if (parsedItems.length === 0) {
await executionContext.interaction.followUp({
embeds: [
this.discordMessageService.buildErrorMessage({
title: 'No results for your search query found',
description: `I was not able to find any matches for your query \`\`${dto.search}\`\`. Please check that I have access to the desired libraries and that your query is not misspelled`,
}),
],
});
return;
}
const firstItems = parsedItems.slice(0, 10);
const lines: string[] = firstItems.map((item, index) => {
let line = `${index + 1}. `;
line += item.prettyPrint(dto.search);
return line;
});
let description =
'I have found **' +
items.length +
'** results for your search ``' +
dto.search +
'``.';
if (items.length > 10) {
description +=
'\nSince the results exceed 10 items, I truncated them for better readability.';
}
description += '\n\n' + lines.join('\n');
const selectOptions: { label: string; value: string; emoji?: string }[] =
firstItems.map((item) => ({
label: item.prettyPrint(dto.search).replace(/\*/g, ''),
value: item.getValueId(),
emoji: item.getEmoji(),
}));
await executionContext.interaction.followUp({
embeds: [
this.discordMessageService.buildMessage({
title: 'Jellyfin Search Results',
description: description,
}),
],
components: [
{
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.StringSelect,
customId: 'searchItemSelect',
options: selectOptions,
},
],
},
],
});
}
@On(Events.InteractionCreate)
async onStringSelect(interaction: Interaction) {
if (!interaction.isStringSelectMenu()) return;
if (interaction.customId !== 'searchItemSelect') {
return;
}
if (interaction.values.length !== 1) {
this.logger.warn(
`Failed to process interaction select with values [${interaction.values.length}]`,
);
return;
}
await interaction.deferUpdate();
await interaction.editReply({
embeds: [
this.discordMessageService.buildMessage({
title: 'Applying your selection to the queue...',
description: `This may take a moment. Please wait`,
}),
],
components: [],
});
const guildMember = interaction.member as GuildMember;
const tryResult =
this.discordVoiceService.tryJoinChannelAndEstablishVoiceConnection(
guildMember,
);
if (!tryResult.success) {
this.logger.warn(
`Unable to process select result because the member was not in a voice channcel`,
);
const replyOptions = tryResult.reply as InteractionReplyOptions;
await interaction.editReply({
embeds: replyOptions.embeds,
content: undefined,
components: [],
});
return;
}
const bitrate = guildMember.voice.channel.bitrate;
const valueParts = interaction.values[0].split('_');
const type = valueParts[0];
const id = valueParts[1];
switch (type) {
case 'track':
const item = await this.jellyfinSearchService.getById(id);
const remoteImagesOfCurrentAlbum =
await this.jellyfinSearchService.getRemoteImageById(item.AlbumId);
const trackRemoteImage = chooseSuitableRemoteImage(
remoteImagesOfCurrentAlbum,
);
const addedIndex = this.enqueueSingleTrack(
item as BaseJellyfinAudioPlayable,
bitrate,
remoteImagesOfCurrentAlbum,
);
await interaction.editReply({
embeds: [
this.discordMessageService.buildMessage({
title: item.Name,
description: `Your track was added to the position ${addedIndex} in the playlist`,
mixin(embedBuilder) {
if (trackRemoteImage === undefined) {
return embedBuilder;
}
return embedBuilder.setThumbnail(trackRemoteImage.Url);
},
}),
],
components: [],
});
break;
case 'album':
const album = await this.jellyfinSearchService.getItemsByAlbum(id);
const remoteImages =
await this.jellyfinSearchService.getRemoteImageById(id);
const albumRemoteImage = chooseSuitableRemoteImage(remoteImages);
album.SearchHints.forEach((item) => {
this.enqueueSingleTrack(
item as BaseJellyfinAudioPlayable,
bitrate,
remoteImages,
);
});
await interaction.editReply({
embeds: [
this.discordMessageService.buildMessage({
title: `Added ${album.TotalRecordCount} items from your album`,
description: `${album.SearchHints.map((item) =>
trimStringToFixedLength(item.Name, 20),
).join(', ')}`,
mixin(embedBuilder) {
if (albumRemoteImage === undefined) {
return embedBuilder;
}
return embedBuilder.setThumbnail(albumRemoteImage.Url);
},
}),
],
components: [],
});
break;
case 'playlist':
const playlist = await this.jellyfinSearchService.getPlaylistById(id);
const addedRemoteImages: RemoteImageResult = {};
for (let index = 0; index < playlist.Items.length; index++) {
const item = playlist.Items[index];
const remoteImages =
await this.jellyfinSearchService.getRemoteImageById(id);
addedRemoteImages.Images.concat(remoteImages.Images);
this.enqueueSingleTrack(
item as BaseJellyfinAudioPlayable,
bitrate,
remoteImages,
);
}
const bestPlaylistRemoteImage =
chooseSuitableRemoteImage(addedRemoteImages);
await interaction.editReply({
embeds: [
this.discordMessageService.buildMessage({
title: `Added ${playlist.TotalRecordCount} items from your playlist`,
description: `${playlist.Items.map((item) =>
trimStringToFixedLength(item.Name, 20),
).join(', ')}`,
mixin(embedBuilder) {
if (bestPlaylistRemoteImage === undefined) {
return embedBuilder;
}
return embedBuilder.setThumbnail(bestPlaylistRemoteImage.Url);
},
}),
],
components: [],
});
break;
default:
await interaction.editReply({
embeds: [
this.discordMessageService.buildErrorMessage({
title: 'Unable to process your selection',
description: `Sorry. I don't know the type you selected: \`\`${type}\`\`. Please report this bug to the developers.\n\nDebug Information: \`\`${interaction.values.join(
', ',
)}\`\``,
}),
],
components: [],
});
break;
}
}
private enqueueSingleTrack(
jellyfinPlayable: BaseJellyfinAudioPlayable,
bitrate: number,
remoteImageResult: RemoteImageResult,
) {
const stream = this.jellyfinStreamBuilder.buildStreamUrl(
jellyfinPlayable.Id,
bitrate,
);
const milliseconds = jellyfinPlayable.RunTimeTicks / 10000;
return this.playbackService.enqueueTrack({
jellyfinId: jellyfinPlayable.Id,
name: jellyfinPlayable.Name,
durationInMilliseconds: milliseconds,
streamUrl: stream,
remoteImages: remoteImageResult,
});
}
}

View File

@ -0,0 +1,168 @@
import { SlashCommandPipe } from '@discord-nestjs/common';
import {
Command,
Handler,
IA,
InteractionEvent,
On,
} from '@discord-nestjs/core';
import { RemoteImageInfo } from '@jellyfin/sdk/lib/generated-client/models';
import { Injectable } from '@nestjs/common';
import { Logger } from '@nestjs/common/services';
import {
CommandInteraction,
Events,
GuildMember,
Interaction,
InteractionReplyOptions,
} from 'discord.js';
import { PlaybackService } from '../../playback/playback.service';
import { formatMillisecondsAsHumanReadable } from '../../utils/timeUtils';
import { DiscordMessageService } from '../../clients/discord/discord.message.service';
import { DiscordVoiceService } from '../../clients/discord/discord.voice.service';
import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service';
import { SearchHint } from '../../models/search/SearchHint';
import { SearchType, PlayCommandParams } from './play.params.ts';
@Injectable()
@Command({
name: 'play',
description: 'Search for an item on your Jellyfin instance',
})
export class PlayItemCommand {
private readonly logger: Logger = new Logger(PlayItemCommand.name);
constructor(
private readonly jellyfinSearchService: JellyfinSearchService,
private readonly discordMessageService: DiscordMessageService,
private readonly discordVoiceService: DiscordVoiceService,
private readonly playbackService: PlaybackService,
) {}
@Handler()
async handler(
@InteractionEvent(SlashCommandPipe) dto: PlayCommandParams,
@IA() interaction: CommandInteraction,
): Promise<InteractionReplyOptions | string> {
await interaction.deferReply({ ephemeral: true });
const baseItems = PlayCommandParams.getBaseItemKinds(dto.type);
let item: SearchHint;
if (dto.name.startsWith('native-')) {
item = await this.jellyfinSearchService.getById(
dto.name.replace('native-', ''),
baseItems,
);
} else {
item = (
await this.jellyfinSearchService.searchItem(dto.name, 1, baseItems)
).find((x) => x);
}
if (!item) {
await interaction.followUp({
embeds: [
this.discordMessageService.buildMessage({
title: 'No results found',
description: `- Check for any misspellings\n- Grant me access to your desired libraries\n- Avoid special characters`,
}),
],
ephemeral: true,
});
return;
}
const guildMember = interaction.member as GuildMember;
const tryResult =
this.discordVoiceService.tryJoinChannelAndEstablishVoiceConnection(
guildMember,
);
if (!tryResult.success) {
const replyOptions = tryResult.reply as InteractionReplyOptions;
await interaction.editReply({
embeds: replyOptions.embeds,
});
return;
}
const tracks = await item.toTracks(this.jellyfinSearchService);
const reducedDuration = tracks.reduce(
(sum, item) => sum + item.duration,
0,
);
this.playbackService.getPlaylistOrDefault().enqueueTracks(tracks);
const remoteImage: RemoteImageInfo | undefined = tracks
.flatMap((x) => x.getRemoteImages())
.find((x) => true);
await interaction.followUp({
embeds: [
this.discordMessageService.buildMessage({
title: `Added ${this.playbackService
.getPlaylistOrDefault()
.getLength()} tracks to your playlist (${formatMillisecondsAsHumanReadable(
reducedDuration,
)})`,
mixin(embedBuilder) {
if (!remoteImage) {
return embedBuilder;
}
return embedBuilder.setThumbnail(remoteImage.Url);
},
}),
],
ephemeral: true,
});
}
@On(Events.InteractionCreate)
async onAutocomplete(interaction: Interaction) {
if (!interaction.isAutocomplete()) {
return;
}
const focusedAutoCompleteAction = interaction.options.getFocused(true);
const typeIndex: number | null = interaction.options.getInteger('type');
const type = Object.values(SearchType)[typeIndex] as SearchType;
const searchQuery = focusedAutoCompleteAction.value;
if (!searchQuery || searchQuery.length < 1) {
await interaction.respond([]);
this.logger.debug(
'Did not attempt a search, because the auto-complete option was empty',
);
return;
}
this.logger.debug(
`Initiating auto-complete search for query '${searchQuery}' with type '${type}'`,
);
const hints = await this.jellyfinSearchService.searchItem(
searchQuery,
20,
PlayCommandParams.getBaseItemKinds(type),
);
if (hints.length === 0) {
await interaction.respond([]);
return;
}
await interaction.respond(
hints.map((hint) => ({
name: hint.toString(),
value: `native-${hint.getId()}`,
})),
);
}
}

View File

@ -0,0 +1,39 @@
import { Choice, Param, ParamType } from '@discord-nestjs/core';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models';
export enum SearchType {
Audio = 0,
AudioAlbum = 1,
Playlist = 2,
}
export class PlayCommandParams {
@Param({
required: true,
description: 'Item name on Jellyfin',
autocomplete: true,
})
name: string;
@Choice(SearchType)
@Param({ description: 'Desired item type', type: ParamType.INTEGER })
type: SearchType | undefined;
static getBaseItemKinds(type: SearchType | undefined) {
switch (type) {
case SearchType.Audio:
return [BaseItemKind.Audio];
case SearchType.Playlist:
return [BaseItemKind.Playlist];
case SearchType.AudioAlbum:
return [BaseItemKind.MusicAlbum];
default:
return [
BaseItemKind.Audio,
BaseItemKind.Playlist,
BaseItemKind.MusicAlbum,
];
}
}
}

View File

@ -1,88 +0,0 @@
import { TransformPipe } from '@discord-nestjs/common';
import { Command, DiscordCommand, UsePipes } from '@discord-nestjs/core';
import { CommandInteraction } from 'discord.js';
import { DiscordMessageService } from '../clients/discord/discord.message.service';
import { PlaybackService } from '../playback/playback.service';
import { Constants } from '../utils/constants';
import { chooseSuitableRemoteImageFromTrack } from '../utils/remoteImages/remoteImages';
import { trimStringToFixedLength } from '../utils/stringUtils/stringUtils';
import { formatMillisecondsAsHumanReadable } from '../utils/timeUtils';
@Command({
name: 'playlist',
description: 'Print the current track information',
})
@UsePipes(TransformPipe)
export class PlaylistCommand implements DiscordCommand {
constructor(
private readonly discordMessageService: DiscordMessageService,
private readonly playbackService: PlaybackService,
) {}
async handler(interaction: CommandInteraction): Promise<void> {
const playList = this.playbackService.getPlaylist();
if (playList.tracks.length === 0) {
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',
}),
],
});
}
const tracklist = playList.tracks
.slice(0, 10)
.map((track, index) => {
const isCurrent = track.id === playList.activeTrack;
let point = this.getListPoint(isCurrent, index);
point += `**${trimStringToFixedLength(track.track.name, 30)}**`;
if (isCurrent) {
point += ' :loud_sound:';
}
point += '\n';
point += Constants.Design.InvisibleSpace.repeat(2);
point += 'Duration: ';
point += formatMillisecondsAsHumanReadable(
track.track.durationInMilliseconds,
);
return point;
})
.join('\n');
const activeTrack = this.playbackService.getActiveTrack();
const remoteImage = chooseSuitableRemoteImageFromTrack(activeTrack.track);
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);
},
}),
],
});
}
private getListPoint(isCurrent: boolean, index: number) {
if (isCurrent) {
return `${index + 1}. `;
}
return `${index + 1}. `;
}
}

View File

@ -0,0 +1,201 @@
import { CollectorInterceptor, SlashCommandPipe } from '@discord-nestjs/common';
import {
AppliedCollectors,
Command,
Handler,
IA,
InteractionEvent,
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';
import { PlaylistCommandParams } from './playlist.params';
@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: PlaylistCommandParams,
@IA() interaction: CommandInteraction,
@AppliedCollectors(0) collector: InteractionCollector<ButtonInteraction>,
): Promise<void> {
const page = dto.page ?? 0;
await interaction.reply(
this.getReplyForPage(page) as InteractionReplyOptions,
);
this.pageData.set(interaction.id, page);
this.logger.debug(
`Added '${interaction.id}' as a message id for page storage`,
);
setTimeout(async () => {
this.logger.log(
`Removed the components of message from interaction '${interaction.id}' because the event collector has reachted the timeout`,
);
this.pageData.delete(interaction.id);
await interaction.editReply({
components: [],
});
}, 60 * 1000);
}
private getChunks() {
const playlist = this.playbackService.getPlaylistOrDefault();
return chunkArray(playlist.tracks, 10);
}
public getReplyForPage(
page: number,
): InteractionReplyOptions | InteractionUpdateOptions {
const chunks = this.getChunks();
if (chunks.length === 0) {
return {
embeds: [
this.discordMessageService.buildMessage({
title: 'There are no items in your playlist',
description:
'Use the ``/play`` command to add new items to your playlist',
}),
],
ephemeral: true,
};
}
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],
fetchReply: true,
};
}
private getContentForPage(
chunks: Track[][],
page: number,
): EmbedBuilder | undefined {
this.logger.verbose(
`Received request for page ${page} of playlist page chunks`,
);
const playlist = this.playbackService.getPlaylistOrDefault();
if (page >= chunks.length || page < 0) {
this.logger.warn(`Request for page chunks was out of range: ${page}`);
return undefined;
}
const offset = page * 10;
const chunk = chunks[page];
if (!chunk) {
this.logger.error(
`Failed to extract chunk from playlist chunks array with page ${page}`,
);
}
const content = chunk
.map((track, index) => {
const isCurrent = track === playlist.getActiveTrack();
// use the offset for the page, add the current index and offset by one because the array index is used
let point = `${offset + index + 1}. `;
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);
}
}

View File

@ -0,0 +1,88 @@
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: 60 * 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);
this.logger.verbose(
`Extracted the target page ${targetPage} from the button 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);
if (current === undefined) {
this.logger.warn(
`Unable to extract the current page from the cause interaction '${this.causeInteraction.id}'`,
);
return undefined;
}
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

@ -0,0 +1,10 @@
import { Param, ParamType } from '@discord-nestjs/core';
export class PlaylistCommandParams {
@Param({
required: false,
description: 'The page',
type: ParamType.INTEGER,
})
page: number;
}

View File

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

View File

@ -1,26 +1,23 @@
import { TransformPipe } from '@discord-nestjs/common';
import { Command, Handler, IA, InjectDiscordClient } from '@discord-nestjs/core';
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
import { Injectable } from '@nestjs/common';
import {
Command,
DiscordCommand,
InjectDiscordClient,
UsePipes,
} from '@discord-nestjs/core';
import { Client, CommandInteraction, Status } from 'discord.js';
import { formatDuration, intervalToDuration } from 'date-fns';
import { Constants } from '../utils/constants';
import { DiscordMessageService } from '../clients/discord/discord.message.service';
import { JellyfinService } from '../clients/jellyfin/jellyfin.service';
import { Constants } from '../utils/constants';
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
@Command({
name: 'status',
description: 'Display the current status for troubleshooting',
})
@UsePipes(TransformPipe)
export class StatusCommand implements DiscordCommand {
@Injectable()
export class StatusCommand {
constructor(
@InjectDiscordClient()
private readonly client: Client,
@ -28,7 +25,8 @@ export class StatusCommand implements DiscordCommand {
private readonly jellyfinService: JellyfinService,
) {}
async handler(interaction: CommandInteraction): Promise<void> {
@Handler()
async handler(@IA() interaction: CommandInteraction): Promise<void> {
await interaction.reply({
embeds: [
this.discordMessageService.buildMessage({

View File

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

View File

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

View File

@ -5,8 +5,11 @@ import {
} from '@nestjs/terminus';
import { HealthCheckExecutor } from '@nestjs/terminus/dist/health-check/health-check-executor.service';
import { Test } from '@nestjs/testing';
import { useDefaultMockerToken } from '../utils/tests/defaultMockerToken';
import { HealthController } from './health.controller';
import { DiscordHealthIndicator } from './indicators/discord.indicator';
import { JellyfinHealthIndicator } from './indicators/jellyfin.indicator';
@ -39,7 +42,7 @@ describe('HealthController', () => {
}
if (token === HealthCheckService) {
return new HealthCheckService(new HealthCheckExecutor(), null);
return new HealthCheckService(new HealthCheckExecutor(), null, null);
}
return useDefaultMockerToken(token);

View File

@ -1,8 +1,30 @@
import { LogLevel } from '@nestjs/common/services';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
function getLoggingLevels(): LogLevel[] {
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'];
}
}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create(AppModule, {
logger: getLoggingLevels(),
});
app.enableShutdownHooks();
await app.listen(process.env.PORT || 3000);
}

View File

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

View File

@ -1,248 +0,0 @@
import {
BaseItemKind,
SearchHint,
} from '@jellyfin/sdk/lib/generated-client/models';
import { JellyfinStreamBuilderService } from '../clients/jellyfin/jellyfin.stream.builder.service';
import { Track } from '../types/track';
import { trimStringToFixedLength } from '../utils/stringUtils/stringUtils';
import { Logger } from '@nestjs/common';
import { JellyfinSearchService } from '../clients/jellyfin/jellyfin.search.service';
export interface BaseJellyfinAudioPlayable {
/**
* The primary identifier of the item
*/
Id: string;
/**
* The name of the item
*/
Name: string;
/**
* The runtime in ticks. 10'000 ticks equal one second
*/
RunTimeTicks: number;
fromSearchHint(
jellyfinSearchService: JellyfinSearchService,
searchHint: SearchHint,
): Promise<BaseJellyfinAudioPlayable>;
fetchTracks(
jellyfinStreamBuilder: JellyfinStreamBuilderService,
bitrate: number,
): Track[];
prettyPrint(search: string): string;
getId(): string;
getValueId(): string;
getEmoji(): string;
}
export class JellyfinAudioItem implements BaseJellyfinAudioPlayable {
Id: string;
Name: string;
RunTimeTicks: number;
ItemId: string;
/**
* The year, when this was produced. Usually something like 2021
*/
ProductionYear?: number;
Album?: string;
AlbumId?: string;
AlbumArtist?: string;
Artists?: string[];
getValueId(): string {
return `track_${this.getId()}`;
}
async fromSearchHint(
jellyfinSearchService: JellyfinSearchService,
searchHint: SearchHint,
): Promise<BaseJellyfinAudioPlayable> {
this.Id = searchHint.Id;
this.ItemId = searchHint.ItemId;
this.Name = searchHint.Name;
this.RunTimeTicks = searchHint.RunTimeTicks;
this.Album = searchHint.Album;
this.AlbumArtist = searchHint.AlbumArtist;
this.AlbumId = searchHint.AlbumId;
this.Artists = searchHint.Artists;
return this;
}
getEmoji(): string {
return '🎵';
}
getId(): string {
return this.Id;
}
prettyPrint(search: string): string {
let line = trimStringToFixedLength(
markSearchTermOverlap(this.Name, search),
30,
);
if (this.Artists !== undefined && this.Artists.length > 0) {
line += ` [${this.Artists.join(', ')}]`;
}
line += ` *(Audio)*`;
return line;
}
fetchTracks(
jellyfinStreamBuilder: JellyfinStreamBuilderService,
bitrate: number,
): Track[] {
return [
{
name: this.Name,
durationInMilliseconds: this.RunTimeTicks / 1000,
jellyfinId: this.Id,
streamUrl: jellyfinStreamBuilder.buildStreamUrl(this.Id, bitrate),
remoteImages: {},
},
];
}
}
export class JellyfinAudioPlaylist implements BaseJellyfinAudioPlayable {
getValueId(): string {
return `playlist_${this.getId()}`;
}
async fromSearchHint(
jellyfinSearchService: JellyfinSearchService,
searchHint: SearchHint,
): Promise<BaseJellyfinAudioPlayable> {
this.Id = searchHint.Id;
this.Name = searchHint.Name;
this.RunTimeTicks = searchHint.RunTimeTicks;
const playlist = await jellyfinSearchService.getPlaylistById(searchHint.Id);
this.Items = playlist.Items;
this.TotalRecordCount = playlist.TotalRecordCount;
return this;
}
getEmoji(): string {
return '📚';
}
getId(): string {
return this.Id;
}
prettyPrint(search: string): string {
return `${markSearchTermOverlap(this.Name, search)} (${
this.TotalRecordCount
} items) (Playlist)`;
}
fetchTracks(
jellyfinStreamBuilder: JellyfinStreamBuilderService,
bitrate: number,
): Track[] {
return this.Items.flatMap((item) =>
item.fetchTracks(jellyfinStreamBuilder, bitrate),
);
}
Id: string;
Name: string;
RunTimeTicks: number;
Items: JellyfinAudioItem[];
TotalRecordCount: number;
}
export class JellyfinMusicAlbum implements BaseJellyfinAudioPlayable {
Id: string;
Name: string;
RunTimeTicks: number;
SearchHints: JellyfinAudioItem[];
TotalRecordCount: number;
async fromSearchHint(
jellyfinSearchService: JellyfinSearchService,
searchHint: SearchHint,
): Promise<JellyfinMusicAlbum> {
this.Id = searchHint.Id;
this.Name = searchHint.Name;
this.RunTimeTicks = searchHint.RunTimeTicks;
const album = await jellyfinSearchService.getItemsByAlbum(searchHint.Id);
this.SearchHints = album.SearchHints;
this.TotalRecordCount = album.TotalRecordCount;
return this;
}
fetchTracks(
jellyfinStreamBuilder: JellyfinStreamBuilderService,
bitrate: number,
): Track[] {
return this.SearchHints.flatMap((item) =>
item.fetchTracks(jellyfinStreamBuilder, bitrate),
);
}
prettyPrint(search: string): string {
return `${markSearchTermOverlap(this.Name, search)} (${
this.TotalRecordCount
} items) (Album)`;
}
getId(): string {
return this.Id;
}
getValueId(): string {
return `album_${this.getId()}`;
}
getEmoji(): string {
return '📀';
}
}
export const searchResultAsJellyfinAudio = async (
logger: Logger,
jellyfinSearchService: JellyfinSearchService,
searchHint: SearchHint,
) => {
switch (searchHint.Type) {
case BaseItemKind[BaseItemKind.Audio]:
return await new JellyfinAudioItem().fromSearchHint(
jellyfinSearchService,
searchHint,
);
case BaseItemKind[BaseItemKind.Playlist]:
return await new JellyfinAudioPlaylist().fromSearchHint(
jellyfinSearchService,
searchHint,
);
case BaseItemKind[BaseItemKind.MusicAlbum]:
return await new JellyfinMusicAlbum().fromSearchHint(
jellyfinSearchService,
searchHint,
);
default:
logger.error(
`Failed to parse Jellyfin response for item type ${searchHint.Type}`,
);
null;
}
};
export const markSearchTermOverlap = (value: string, searchTerm: string) => {
const startIndex = value.indexOf(searchTerm);
const actualValue = value.substring(
startIndex,
startIndex + 1 + searchTerm.length,
);
return `${value.substring(0, startIndex)}**${actualValue}**${value.substring(
startIndex + 1 + actualValue.length,
)}`;
};

View File

@ -0,0 +1,26 @@
import { SearchHint as JellyfinSearchHint } from '@jellyfin/sdk/lib/generated-client/models';
import { Track } from '../shared/Track';
import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service';
import { SearchHint } from './SearchHint';
export class AlbumSearchHint extends SearchHint {
override toString(): string {
return `🎶 ${this.name}`;
}
static constructFromHint(hint: JellyfinSearchHint) {
return new AlbumSearchHint(hint.Id, hint.Name, hint.RunTimeTicks / 10000);
}
override async toTracks(
searchService: JellyfinSearchService,
): Promise<Track[]> {
const albumItems = await searchService.getAlbumItems(this.id);
const tracks = albumItems.map(async (x) =>
(await x.toTracks(searchService)).find((x) => x !== null),
);
return await Promise.all(tracks);
}
}

View File

@ -0,0 +1,30 @@
import { SearchHint as JellyfinSearchHint } from '@jellyfin/sdk/lib/generated-client/models';
import { Track } from '../shared/Track';
import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service';
import { SearchHint } from './SearchHint';
export class PlaylistSearchHint extends SearchHint {
override toString(): string {
return `🎧 ${this.name}`;
}
static constructFromHint(hint: JellyfinSearchHint) {
return new PlaylistSearchHint(
hint.Id,
hint.Name,
hint.RunTimeTicks / 10000,
);
}
override async toTracks(
searchService: JellyfinSearchService,
): Promise<Track[]> {
const playlistItems = await searchService.getPlaylistitems(this.id);
const tracks = playlistItems.map(async (x) =>
(await x.toTracks(searchService)).find((x) => x !== null),
);
return await Promise.all(tracks);
}
}

View File

@ -0,0 +1,31 @@
import { SearchHint as JellyfinSearchHint } from '@jellyfin/sdk/lib/generated-client/models';
import { Track } from '../shared/Track';
import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service';
export class SearchHint {
constructor(
protected readonly id: string,
protected readonly name: string,
protected runtimeInMilliseconds: number,
) {}
toString() {
return `🎵 ${this.name}`;
}
async toTracks(searchService: JellyfinSearchService): Promise<Track[]> {
const remoteImages = await searchService.getRemoteImageById(this.id);
return [
new Track(this.id, this.name, this.runtimeInMilliseconds, remoteImages),
];
}
getId(): string {
return this.id;
}
static constructFromHint(hint: JellyfinSearchHint) {
return new SearchHint(hint.Id, hint.Name, hint.RunTimeTicks / 10000);
}
}

View File

@ -0,0 +1,142 @@
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Track } from './Track';
export class Playlist {
tracks: Track[];
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(): Track | undefined {
if (this.isActiveTrackOutOfSync()) {
return undefined;
}
return this.tracks[this.activeTrackIndex];
}
isEmpty(): boolean {
return this.tracks.length === 0;
}
hasActiveTrack(): boolean {
return (
this.activeTrackIndex !== undefined && !this.isActiveTrackOutOfSync()
);
}
getLength() {
return this.tracks.length;
}
/**
* 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: Track[]) {
this.eventEmitter.emit('controls.playlist.tracks.enqueued', {
count: tracks.length,
activeTrack: this.activeTrackIndex,
});
const length = this.tracks.push(...tracks);
// emit a track change if there is no item
if (this.activeTrackIndex === undefined) {
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 + 1 < this.tracks.length;
}
/**
* Check if there is a previous track
* @returns if there is a previous track in the playlist
*/
hasPreviousTrackInPlaylist() {
return this.activeTrackIndex > 0;
}
clear() {
this.eventEmitter.emit('controls.playlist.tracks.clear');
this.tracks = [];
this.activeTrackIndex = undefined;
}
private announceTrackChange() {
if (!this.activeTrackIndex) {
this.activeTrackIndex = 0;
}
this.eventEmitter.emit('internal.audio.announce', this.getActiveTrack());
}
private isActiveTrackOutOfSync(): boolean {
return (
this.activeTrackIndex < 0 || this.activeTrackIndex >= this.tracks.length
);
}
}
export type PlaylistPlaybackType =
| 'once'
| 'repeat-once'
| 'repeat-indefinetly'
| 'shuffle';

View File

@ -0,0 +1,53 @@
import {
RemoteImageInfo,
RemoteImageResult,
} from '@jellyfin/sdk/lib/generated-client/models';
import { JellyfinStreamBuilderService } from '../../clients/jellyfin/jellyfin.stream.builder.service';
export class Track {
/**
* The identifier of this track, structured as a UID.
* 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);
}
getRemoteImages(): RemoteImageInfo[] {
return this.remoteImages.Images;
}
}

View File

@ -1,6 +0,0 @@
import { Param } from '@discord-nestjs/core';
export class TrackRequestDto {
@Param({ required: true, description: 'Track name to search' })
search: string;
}

View File

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

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

@ -3,7 +3,7 @@ export const Constants = {
Version: {
Major: 0,
Minor: 0,
Patch: 4,
Patch: 5,
All: () =>
`${Constants.Metadata.Version.Major}.${Constants.Metadata.Version.Minor}.${Constants.Metadata.Version.Patch}`,
},
@ -33,11 +33,11 @@ export const Constants = {
InvisibleSpace: '\u1CBC',
Icons: {
JellyfinLogo:
'https://github.com/manuel-rw/jellyfin-discord-music-bot/blob/nestjs-migration/images/icons/jellyfin-icon-squared.png?raw=true',
'https://raw.githubusercontent.com/manuel-rw/jellyfin-discord-music-bot/master/images/icons/jellyfin-icon-squared.png',
SuccessIcon:
'https://github.com/manuel-rw/jellyfin-discord-music-bot/blob/nestjs-migration/images/icons/circle-check.png?raw=true',
'https://raw.githubusercontent.com/manuel-rw/jellyfin-discord-music-bot/master/images/icons/circle-check.png',
ErrorIcon:
'https://github.com/manuel-rw/jellyfin-discord-music-bot/blob/nestjs-migration/images/icons/alert-circle.png?raw=true',
'https://raw.githubusercontent.com/manuel-rw/jellyfin-discord-music-bot/master/images/icons/alert-circle.png',
},
},
};

View File

@ -1,29 +0,0 @@
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models';
import { chooseSuitableRemoteImageFromTrack } from './remoteImages';
describe('remoteImages', () => {
it('chooseSuitableRemoteImageFromTrack', () => {
const remoteImage = chooseSuitableRemoteImageFromTrack({
name: 'Testing Music',
durationInMilliseconds: 6969,
jellyfinId: '7384783',
remoteImages: {
Images: [
{
Type: ImageType.Primary,
Url: 'nice picture.png',
},
{
Type: ImageType.Screenshot,
Url: 'not nice picture',
},
],
},
streamUrl: 'http://jellyfin/example-stream',
});
expect(remoteImage).not.toBeNull();
expect(remoteImage.Type).toBe(ImageType.Primary);
expect(remoteImage.Url).toBe('nice picture.png');
});
});

View File

@ -1,25 +0,0 @@
import {
ImageType,
RemoteImageInfo,
RemoteImageResult,
} from '@jellyfin/sdk/lib/generated-client/models';
import { Track } from '../../types/track';
export const chooseSuitableRemoteImage = (
remoteImageResult: RemoteImageResult,
): RemoteImageInfo | undefined => {
const primaryImages: RemoteImageInfo[] | undefined =
remoteImageResult.Images.filter((x) => x.Type === ImageType.Primary);
if (primaryImages.length > 0) {
return primaryImages[0];
}
if (remoteImageResult.Images.length > 0) {
return remoteImageResult.Images[0];
}
};
export const chooseSuitableRemoteImageFromTrack = (track: Track) => {
return chooseSuitableRemoteImage(track.remoteImages);
};

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

13074
yarn.lock

File diff suppressed because it is too large Load Diff