mirror of
https://github.com/informaticker/discord-jellyfin-bot.git
synced 2024-11-25 02:51:57 +01:00
commit
a1ca1eafbc
9763
package-lock.json
generated
9763
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jellyfin-discord-music-bot",
|
"name": "jellyfin-discord-music-bot",
|
||||||
"version": "0.0.1",
|
"version": "0.0.3",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "manuel-rw",
|
"author": "manuel-rw",
|
||||||
"private": true,
|
"private": true,
|
||||||
@ -32,6 +32,7 @@
|
|||||||
"@nestjs/event-emitter": "^1.3.1",
|
"@nestjs/event-emitter": "^1.3.1",
|
||||||
"@nestjs/platform-express": "^9.0.0",
|
"@nestjs/platform-express": "^9.0.0",
|
||||||
"@nestjs/schedule": "^2.1.0",
|
"@nestjs/schedule": "^2.1.0",
|
||||||
|
"@nestjs/terminus": "^9.1.4",
|
||||||
"date-fns": "^2.29.3",
|
"date-fns": "^2.29.3",
|
||||||
"discord.js": "^14.7.1",
|
"discord.js": "^14.7.1",
|
||||||
"joi": "^17.7.0",
|
"joi": "^17.7.0",
|
||||||
@ -39,7 +40,8 @@
|
|||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"rxjs": "^7.2.0",
|
"rxjs": "^7.2.0",
|
||||||
"uuid": "^9.0.0"
|
"uuid": "^9.0.0",
|
||||||
|
"ws": "^8.11.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^9.0.0",
|
"@nestjs/cli": "^9.0.0",
|
||||||
@ -48,7 +50,7 @@
|
|||||||
"@types/cron": "^2.0.0",
|
"@types/cron": "^2.0.0",
|
||||||
"@types/express": "^4.17.13",
|
"@types/express": "^4.17.13",
|
||||||
"@types/jest": "28.1.8",
|
"@types/jest": "28.1.8",
|
||||||
"@types/node": "^16.0.0",
|
"@types/node": "^18.0.0",
|
||||||
"@types/supertest": "^2.0.11",
|
"@types/supertest": "^2.0.11",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||||
"@typescript-eslint/parser": "^5.0.0",
|
"@typescript-eslint/parser": "^5.0.0",
|
||||||
|
@ -10,6 +10,7 @@ import { DiscordConfigService } from './clients/discord/discord.config.service';
|
|||||||
import { DiscordClientModule } from './clients/discord/discord.module';
|
import { DiscordClientModule } from './clients/discord/discord.module';
|
||||||
import { JellyfinClientModule } from './clients/jellyfin/jellyfin.module';
|
import { JellyfinClientModule } from './clients/jellyfin/jellyfin.module';
|
||||||
import { CommandModule } from './commands/command.module';
|
import { CommandModule } from './commands/command.module';
|
||||||
|
import { HealthModule } from './health/health.module';
|
||||||
import { PlaybackModule } from './playback/playback.module';
|
import { PlaybackModule } from './playback/playback.module';
|
||||||
import { UpdatesModule } from './updates/updates.module';
|
import { UpdatesModule } from './updates/updates.module';
|
||||||
|
|
||||||
@ -36,6 +37,7 @@ import { UpdatesModule } from './updates/updates.module';
|
|||||||
JellyfinClientModule,
|
JellyfinClientModule,
|
||||||
PlaybackModule,
|
PlaybackModule,
|
||||||
UpdatesModule,
|
UpdatesModule,
|
||||||
|
HealthModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
|
@ -3,12 +3,13 @@ import { Module } from '@nestjs/common';
|
|||||||
import { OnModuleDestroy } from '@nestjs/common/interfaces/hooks';
|
import { OnModuleDestroy } from '@nestjs/common/interfaces/hooks';
|
||||||
import { CommandExecutionError } from '../../middleware/command-execution-filter';
|
import { CommandExecutionError } from '../../middleware/command-execution-filter';
|
||||||
import { PlaybackModule } from '../../playback/playback.module';
|
import { PlaybackModule } from '../../playback/playback.module';
|
||||||
|
import { JellyfinClientModule } from '../jellyfin/jellyfin.module';
|
||||||
import { DiscordConfigService } from './discord.config.service';
|
import { DiscordConfigService } from './discord.config.service';
|
||||||
import { DiscordMessageService } from './discord.message.service';
|
import { DiscordMessageService } from './discord.message.service';
|
||||||
import { DiscordVoiceService } from './discord.voice.service';
|
import { DiscordVoiceService } from './discord.voice.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlaybackModule],
|
imports: [PlaybackModule, JellyfinClientModule],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [
|
||||||
DiscordConfigService,
|
DiscordConfigService,
|
||||||
|
@ -11,11 +11,12 @@ import {
|
|||||||
} from '@discordjs/voice';
|
} from '@discordjs/voice';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Logger } from '@nestjs/common/services';
|
import { Logger } from '@nestjs/common/services';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||||
import { GuildMember } from 'discord.js';
|
import { GuildMember } from 'discord.js';
|
||||||
import { GenericTryHandler } from '../../models/generic-try-handler';
|
import { GenericTryHandler } from '../../models/generic-try-handler';
|
||||||
import { PlaybackService } from '../../playback/playback.service';
|
import { PlaybackService } from '../../playback/playback.service';
|
||||||
import { Track } from '../../types/track';
|
import { Track } from '../../types/track';
|
||||||
|
import { JellyfinWebSocketService } from '../jellyfin/jellyfin.websocket.service';
|
||||||
import { DiscordMessageService } from './discord.message.service';
|
import { DiscordMessageService } from './discord.message.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -27,6 +28,8 @@ export class DiscordVoiceService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly discordMessageService: DiscordMessageService,
|
private readonly discordMessageService: DiscordMessageService,
|
||||||
private readonly playbackService: PlaybackService,
|
private readonly playbackService: PlaybackService,
|
||||||
|
private readonly jellyfinWebSocketService: JellyfinWebSocketService,
|
||||||
|
private readonly eventEmitter: EventEmitter2,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@OnEvent('playback.newTrack')
|
@OnEvent('playback.newTrack')
|
||||||
@ -74,6 +77,8 @@ export class DiscordVoiceService {
|
|||||||
guildId: channel.guildId,
|
guildId: channel.guildId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.jellyfinWebSocketService.initializeAndConnect();
|
||||||
|
|
||||||
if (this.voiceConnection == undefined) {
|
if (this.voiceConnection == undefined) {
|
||||||
this.voiceConnection = getVoiceConnection(member.guild.id);
|
this.voiceConnection = getVoiceConnection(member.guild.id);
|
||||||
}
|
}
|
||||||
@ -91,15 +96,20 @@ export class DiscordVoiceService {
|
|||||||
/**
|
/**
|
||||||
* Pauses the current audio player
|
* Pauses the current audio player
|
||||||
*/
|
*/
|
||||||
|
@OnEvent('playback.control.pause')
|
||||||
pause() {
|
pause() {
|
||||||
this.createAndReturnOrGetAudioPlayer().pause();
|
this.createAndReturnOrGetAudioPlayer().pause();
|
||||||
|
this.eventEmitter.emit('playback.state.pause', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stops the audio player
|
* Stops the audio player
|
||||||
*/
|
*/
|
||||||
|
@OnEvent('playback.control.stop')
|
||||||
stop(force: boolean): boolean {
|
stop(force: boolean): boolean {
|
||||||
return this.createAndReturnOrGetAudioPlayer().stop(force);
|
const stopped = this.createAndReturnOrGetAudioPlayer().stop(force);
|
||||||
|
this.eventEmitter.emit('playback.state.stop');
|
||||||
|
return stopped;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -107,6 +117,7 @@ export class DiscordVoiceService {
|
|||||||
*/
|
*/
|
||||||
unpause() {
|
unpause() {
|
||||||
this.createAndReturnOrGetAudioPlayer().unpause();
|
this.createAndReturnOrGetAudioPlayer().unpause();
|
||||||
|
this.eventEmitter.emit('playback.state.pause', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -132,6 +143,7 @@ export class DiscordVoiceService {
|
|||||||
* Checks if the current state is paused or not and toggles the states to the opposite.
|
* Checks if the current state is paused or not and toggles the states to the opposite.
|
||||||
* @returns The new paused state - true: paused, false: unpaused
|
* @returns The new paused state - true: paused, false: unpaused
|
||||||
*/
|
*/
|
||||||
|
@OnEvent('playback.control.togglePause')
|
||||||
togglePaused(): boolean {
|
togglePaused(): boolean {
|
||||||
if (this.isPaused()) {
|
if (this.isPaused()) {
|
||||||
this.unpause();
|
this.unpause();
|
||||||
|
@ -2,14 +2,17 @@ import { Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
|||||||
import { JellyfinSearchService } from './jellyfin.search.service';
|
import { JellyfinSearchService } from './jellyfin.search.service';
|
||||||
import { JellyfinService } from './jellyfin.service';
|
import { JellyfinService } from './jellyfin.service';
|
||||||
import { JellyfinStreamBuilderService } from './jellyfin.stream.builder.service';
|
import { JellyfinStreamBuilderService } from './jellyfin.stream.builder.service';
|
||||||
import { JellyinWebsocketService } from './jellyfin.websocket.service';
|
import { JellyinPlaystateService } from './jellyfin.playstate.service';
|
||||||
|
import { JellyfinWebSocketService } from './jellyfin.websocket.service';
|
||||||
|
import { PlaybackModule } from './../../playback/playback.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
imports: [PlaybackModule],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [
|
||||||
JellyfinService,
|
JellyfinService,
|
||||||
JellyinWebsocketService,
|
JellyinPlaystateService,
|
||||||
|
JellyfinWebSocketService,
|
||||||
JellyfinSearchService,
|
JellyfinSearchService,
|
||||||
JellyfinStreamBuilderService,
|
JellyfinStreamBuilderService,
|
||||||
],
|
],
|
||||||
@ -17,6 +20,7 @@ import { JellyinWebsocketService } from './jellyfin.websocket.service';
|
|||||||
JellyfinService,
|
JellyfinService,
|
||||||
JellyfinSearchService,
|
JellyfinSearchService,
|
||||||
JellyfinStreamBuilderService,
|
JellyfinStreamBuilderService,
|
||||||
|
JellyfinWebSocketService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class JellyfinClientModule implements OnModuleInit, OnModuleDestroy {
|
export class JellyfinClientModule implements OnModuleInit, OnModuleDestroy {
|
||||||
|
79
src/clients/jellyfin/jellyfin.playstate.service.ts
Normal file
79
src/clients/jellyfin/jellyfin.playstate.service.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
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';
|
||||||
|
import {
|
||||||
|
BaseItemKind,
|
||||||
|
GeneralCommandType,
|
||||||
|
} 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 { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { Track } from '../../types/track';
|
||||||
|
import { PlaybackService } from '../../playback/playback.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JellyinPlaystateService {
|
||||||
|
private playstateApi: PlaystateApi;
|
||||||
|
private sessionApi: SessionApi;
|
||||||
|
|
||||||
|
constructor(private readonly playbackService: PlaybackService) {}
|
||||||
|
|
||||||
|
private readonly logger = new Logger(JellyinPlaystateService.name);
|
||||||
|
|
||||||
|
async initializePlayState(api: Api) {
|
||||||
|
this.initializeApis(api);
|
||||||
|
await this.reportCapabilities();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initializeApis(api: Api) {
|
||||||
|
this.sessionApi = getSessionApi(api);
|
||||||
|
this.playstateApi = getPlaystateApi(api);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reportCapabilities() {
|
||||||
|
await this.sessionApi.postCapabilities({
|
||||||
|
playableMediaTypes: [BaseItemKind[BaseItemKind.Audio]],
|
||||||
|
supportsMediaControl: true,
|
||||||
|
supportedCommands: [
|
||||||
|
GeneralCommandType.Play,
|
||||||
|
GeneralCommandType.PlayState,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug('Reported playback capabilities sucessfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent('playback.newTrack')
|
||||||
|
private async onPlaybackNewTrack(track: Track) {
|
||||||
|
await this.playstateApi.reportPlaybackStart({
|
||||||
|
playbackStartInfo: {
|
||||||
|
ItemId: track.jellyfinId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent('playback.state.pause')
|
||||||
|
private async onPlaybackPaused(isPaused: boolean) {
|
||||||
|
const activeTrack = this.playbackService.getActiveTrack();
|
||||||
|
|
||||||
|
await this.playstateApi.reportPlaybackProgress({
|
||||||
|
playbackProgressInfo: {
|
||||||
|
ItemId: activeTrack.track.jellyfinId,
|
||||||
|
IsPaused: isPaused,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent('playback.state.stop')
|
||||||
|
private async onPlaybackStopped() {
|
||||||
|
const activeTrack = this.playbackService.getActiveTrack();
|
||||||
|
|
||||||
|
await this.playstateApi.reportPlaybackStopped({
|
||||||
|
playbackStopInfo: {
|
||||||
|
ItemId: activeTrack.track.jellyfinId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -95,6 +95,7 @@ export class JellyfinSearchService {
|
|||||||
const searchApi = getItemsApi(api);
|
const searchApi = getItemsApi(api);
|
||||||
const { data } = await searchApi.getItems({
|
const { data } = await searchApi.getItems({
|
||||||
ids: [id],
|
ids: [id],
|
||||||
|
userId: this.jellyfinService.getUserId(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.Items.length !== 1) {
|
if (data.Items.length !== 1) {
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
import { Api, Jellyfin } from '@jellyfin/sdk';
|
import { Api, Jellyfin } from '@jellyfin/sdk';
|
||||||
import { Constants } from '../../utils/constants';
|
|
||||||
import { SystemApi } from '@jellyfin/sdk/lib/generated-client/api/system-api';
|
import { SystemApi } from '@jellyfin/sdk/lib/generated-client/api/system-api';
|
||||||
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
|
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { Constants } from '../../utils/constants';
|
||||||
|
import { JellyinPlaystateService } from './jellyfin.playstate.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JellyfinService {
|
export class JellyfinService {
|
||||||
@ -13,8 +14,12 @@ export class JellyfinService {
|
|||||||
private api: Api;
|
private api: Api;
|
||||||
private systemApi: SystemApi;
|
private systemApi: SystemApi;
|
||||||
private userId: string;
|
private userId: string;
|
||||||
|
private connected = false;
|
||||||
|
|
||||||
constructor(private readonly eventEmitter: EventEmitter2) {}
|
constructor(
|
||||||
|
private eventEmitter: EventEmitter2,
|
||||||
|
private readonly jellyfinPlayState: JellyinPlaystateService,
|
||||||
|
) {}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.jellyfin = new Jellyfin({
|
this.jellyfin = new Jellyfin({
|
||||||
@ -38,7 +43,7 @@ export class JellyfinService {
|
|||||||
process.env.JELLYFIN_AUTHENTICATION_USERNAME,
|
process.env.JELLYFIN_AUTHENTICATION_USERNAME,
|
||||||
process.env.JELLYFIN_AUTHENTICATION_PASSWORD,
|
process.env.JELLYFIN_AUTHENTICATION_PASSWORD,
|
||||||
)
|
)
|
||||||
.then((response) => {
|
.then(async (response) => {
|
||||||
if (response.data.SessionInfo === undefined) {
|
if (response.data.SessionInfo === undefined) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to authenticate with response code ${response.status}: '${response.data}'`,
|
`Failed to authenticate with response code ${response.status}: '${response.data}'`,
|
||||||
@ -52,11 +57,13 @@ export class JellyfinService {
|
|||||||
this.userId = response.data.SessionInfo.UserId;
|
this.userId = response.data.SessionInfo.UserId;
|
||||||
|
|
||||||
this.systemApi = getSystemApi(this.api);
|
this.systemApi = getSystemApi(this.api);
|
||||||
|
this.connected = true;
|
||||||
|
|
||||||
this.eventEmitter.emit('clients.jellyfin.ready');
|
await this.jellyfinPlayState.initializePlayState(this.api);
|
||||||
})
|
})
|
||||||
.catch((test) => {
|
.catch((test) => {
|
||||||
this.logger.error(test);
|
this.logger.error(test);
|
||||||
|
this.connected = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,6 +75,7 @@ export class JellyfinService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.api.logout();
|
this.api.logout();
|
||||||
|
this.connected = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
getApi() {
|
getApi() {
|
||||||
@ -85,4 +93,8 @@ export class JellyfinService {
|
|||||||
getUserId() {
|
getUserId() {
|
||||||
return this.userId;
|
return this.userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isConnected() {
|
||||||
|
return this.connected;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,23 +1,216 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { Cron } from '@nestjs/schedule';
|
||||||
import { JellyfinService } from './jellyfin.service';
|
import { JellyfinService } from './jellyfin.service';
|
||||||
|
|
||||||
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api/playstate-api';
|
import {
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
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 { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JellyinWebsocketService {
|
export class JellyfinWebSocketService implements OnModuleDestroy {
|
||||||
constructor(private readonly jellyfinClientManager: JellyfinService) {}
|
private webSocket: WebSocket;
|
||||||
|
|
||||||
@OnEvent('clients.jellyfin.ready')
|
private readonly logger = new Logger(JellyfinWebSocketService.name);
|
||||||
handleJellyfinBotReady() {
|
|
||||||
console.log('ready!');
|
|
||||||
|
|
||||||
this.openSocket();
|
constructor(
|
||||||
|
private readonly jellyfinService: JellyfinService,
|
||||||
|
private readonly jellyfinSearchService: JellyfinSearchService,
|
||||||
|
private readonly playbackService: PlaybackService,
|
||||||
|
private readonly jellyfinStreamBuilderService: JellyfinStreamBuilderService,
|
||||||
|
private readonly eventEmitter: EventEmitter2,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Cron('*/30 * * * * *')
|
||||||
|
private handlePeriodicAliveMessage() {
|
||||||
|
if (
|
||||||
|
this.webSocket === undefined ||
|
||||||
|
this.webSocket.readyState !== WebSocket.OPEN
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sendMessage('KeepAlive');
|
||||||
|
this.logger.debug('Sent a KeepAlive package to the server');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async openSocket() {
|
initializeAndConnect() {
|
||||||
const systemApi = getPlaystateApi(this.jellyfinClientManager.getApi());
|
const deviceId = this.jellyfinService.getJellyfin().deviceInfo.id;
|
||||||
|
const url = this.buildSocketUrl(
|
||||||
|
this.jellyfinService.getApi().basePath,
|
||||||
|
this.jellyfinService.getApi().accessToken,
|
||||||
|
deviceId,
|
||||||
|
);
|
||||||
|
|
||||||
// TODO: Write socket playstate api to report playback progress
|
this.logger.debug(`Opening WebSocket with client id ${deviceId}...`);
|
||||||
|
|
||||||
|
this.webSocket = new WebSocket(url);
|
||||||
|
this.bindWebSocketEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
if (!this.webSocket) {
|
||||||
|
this.logger.warn(
|
||||||
|
'Tried to disconnect but WebSocket was unexpectitly undefined',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug('Closing WebSocket...');
|
||||||
|
this.webSocket.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
sendMessage(type: string, data?: any) {
|
||||||
|
if (!this.webSocket || this.webSocket.readyState !== WebSocket.OPEN) {
|
||||||
|
throw new Error('Socket not open');
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj: Record<string, any> = { MessageType: type };
|
||||||
|
if (data) obj.Data = data;
|
||||||
|
|
||||||
|
this.webSocket.send(JSON.stringify(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
getReadyState() {
|
||||||
|
return this.webSocket.readyState;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected messageHandler(data: any) {
|
||||||
|
const msg: JellyMessage<unknown> = JSON.parse(data);
|
||||||
|
|
||||||
|
switch (msg.MessageType) {
|
||||||
|
case SessionMessageType[SessionMessageType.KeepAlive]:
|
||||||
|
case SessionMessageType[SessionMessageType.ForceKeepAlive]:
|
||||||
|
this.logger.debug(
|
||||||
|
`Received a ${msg.MessageType} package from the server`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case SessionMessageType[SessionMessageType.Play]:
|
||||||
|
const data = msg.Data as PlayNowCommand;
|
||||||
|
data.hasSelection = PlayNowCommand.prototype.hasSelection;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case SessionMessageType[SessionMessageType.Playstate]:
|
||||||
|
const sendPlaystateCommandRequest =
|
||||||
|
msg.Data as SessionApiSendPlaystateCommandRequest;
|
||||||
|
this.handleSendPlaystateCommandRequest(sendPlaystateCommandRequest);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.logger.warn(
|
||||||
|
`Received a package from the socket of unknown type: ${msg.MessageType}`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSendPlaystateCommandRequest(
|
||||||
|
request: SessionApiSendPlaystateCommandRequest,
|
||||||
|
) {
|
||||||
|
switch (request.Command) {
|
||||||
|
case PlaystateCommand.PlayPause:
|
||||||
|
this.eventEmitter.emitAsync('playback.control.togglePause');
|
||||||
|
break;
|
||||||
|
case PlaystateCommand.Pause:
|
||||||
|
this.eventEmitter.emitAsync('playback.control.pause');
|
||||||
|
break;
|
||||||
|
case PlaystateCommand.Stop:
|
||||||
|
this.eventEmitter.emitAsync('playback.control.stop');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.logger.warn(
|
||||||
|
`Unable to process incoming playstate command request: ${request.Command}`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bindWebSocketEvents() {
|
||||||
|
this.webSocket.on('message', this.messageHandler.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSocketUrl(baseName: string, apiToken: string, device: string) {
|
||||||
|
const url = new URL(baseName);
|
||||||
|
url.pathname = '/socket';
|
||||||
|
url.protocol = url.protocol.replace('http', 'ws');
|
||||||
|
url.search = `?api_key=${apiToken}&deviceId=${device}`;
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy() {
|
||||||
|
this.disconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface JellyMessage<T> {
|
||||||
|
MessageType: string;
|
||||||
|
MessageId?: string;
|
||||||
|
Data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JellySockEvents {
|
||||||
|
connected: (s: JellySock, ws: WebSocket) => any;
|
||||||
|
message: (s: JellySock, msg: JellyMessage<any>) => any;
|
||||||
|
disconnected: () => any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare interface JellySock {
|
||||||
|
on<U extends keyof JellySockEvents>(
|
||||||
|
event: U,
|
||||||
|
listener: JellySockEvents[U],
|
||||||
|
): this;
|
||||||
|
|
||||||
|
once<U extends keyof JellySockEvents>(
|
||||||
|
event: U,
|
||||||
|
listener: JellySockEvents[U],
|
||||||
|
): this;
|
||||||
|
|
||||||
|
emit<U extends keyof JellySockEvents>(
|
||||||
|
event: U,
|
||||||
|
...args: Parameters<JellySockEvents[U]>
|
||||||
|
): boolean;
|
||||||
|
}
|
||||||
|
@ -36,6 +36,8 @@ export class SummonCommand implements DiscordCommand {
|
|||||||
embeds: [
|
embeds: [
|
||||||
this.discordMessageService.buildMessage({
|
this.discordMessageService.buildMessage({
|
||||||
title: 'Joined your voicehannel',
|
title: 'Joined your voicehannel',
|
||||||
|
description:
|
||||||
|
"I'm ready to play media. Use ``Cast to device`` in Jellyfin or the ``/play`` command to get started.",
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
18
src/health/health.controller.spec.ts
Normal file
18
src/health/health.controller.spec.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
|
||||||
|
describe('HealthController', () => {
|
||||||
|
let controller: HealthController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [HealthController],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<HealthController>(HealthController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
23
src/health/health.controller.ts
Normal file
23
src/health/health.controller.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Controller, Get, Injectable } from '@nestjs/common';
|
||||||
|
import { HealthCheck, HealthCheckService } from '@nestjs/terminus';
|
||||||
|
import { DiscordHealthIndicator } from './indicators/discord.indicator';
|
||||||
|
import { JellyfinHealthIndicator } from './indicators/jellyfin.indicator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
constructor(
|
||||||
|
private readonly health: HealthCheckService,
|
||||||
|
private readonly discordIndicator: DiscordHealthIndicator,
|
||||||
|
private readonly jellyfinHealthIndicator: JellyfinHealthIndicator,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@HealthCheck()
|
||||||
|
healthCheck() {
|
||||||
|
return this.health.check([
|
||||||
|
() => this.discordIndicator.isHealthy('discord'),
|
||||||
|
() => this.jellyfinHealthIndicator.isHealthy('jellyfin'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
14
src/health/health.module.ts
Normal file
14
src/health/health.module.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { DiscordModule } from '@discord-nestjs/core';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TerminusModule } from '@nestjs/terminus';
|
||||||
|
import { JellyfinClientModule } from '../clients/jellyfin/jellyfin.module';
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
import { DiscordHealthIndicator } from './indicators/discord.indicator';
|
||||||
|
import { JellyfinHealthIndicator } from './indicators/jellyfin.indicator';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TerminusModule, JellyfinClientModule, DiscordModule.forFeature()],
|
||||||
|
controllers: [HealthController],
|
||||||
|
providers: [JellyfinHealthIndicator, DiscordHealthIndicator],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
20
src/health/indicators/discord.indicator.ts
Normal file
20
src/health/indicators/discord.indicator.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { InjectDiscordClient } from '@discord-nestjs/core';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { HealthIndicator, HealthIndicatorResult } from '@nestjs/terminus';
|
||||||
|
import { Client, Status } from 'discord.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DiscordHealthIndicator extends HealthIndicator {
|
||||||
|
constructor(@InjectDiscordClient() private readonly client: Client) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async isHealthy(key: string): Promise<HealthIndicatorResult> {
|
||||||
|
const status = this.client.ws.status;
|
||||||
|
|
||||||
|
return this.getStatus(key, status === Status.Ready, {
|
||||||
|
wsStatus: status,
|
||||||
|
pingInMilliseconds: this.client.ws.ping,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
16
src/health/indicators/jellyfin.indicator.ts
Normal file
16
src/health/indicators/jellyfin.indicator.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { HealthIndicator, HealthIndicatorResult } from '@nestjs/terminus';
|
||||||
|
import { JellyfinService } from '../../clients/jellyfin/jellyfin.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JellyfinHealthIndicator extends HealthIndicator {
|
||||||
|
constructor(private readonly jellyfinService: JellyfinService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async isHealthy(key: string): Promise<HealthIndicatorResult> {
|
||||||
|
const isConnected = this.jellyfinService.isConnected();
|
||||||
|
|
||||||
|
return this.getStatus(key, isConnected);
|
||||||
|
}
|
||||||
|
}
|
@ -43,7 +43,7 @@ export class PlaybackService {
|
|||||||
|
|
||||||
const newKey = keys[index + 1];
|
const newKey = keys[index + 1];
|
||||||
this.setActiveTrack(newKey);
|
this.setActiveTrack(newKey);
|
||||||
this.controlAudioPlayer();
|
this.getActiveTrackAndEmitEvent();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,7 +60,7 @@ export class PlaybackService {
|
|||||||
const keys = this.getTrackIds();
|
const keys = this.getTrackIds();
|
||||||
const newKey = keys[index - 1];
|
const newKey = keys[index - 1];
|
||||||
this.setActiveTrack(newKey);
|
this.setActiveTrack(newKey);
|
||||||
this.controlAudioPlayer();
|
this.getActiveTrackAndEmitEvent();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,10 +80,22 @@ export class PlaybackService {
|
|||||||
|
|
||||||
if (emptyBefore) {
|
if (emptyBefore) {
|
||||||
this.setActiveTrack(this.playlist.tracks.find((x) => x.id === uuid).id);
|
this.setActiveTrack(this.playlist.tracks.find((x) => x.id === uuid).id);
|
||||||
this.controlAudioPlayer();
|
this.getActiveTrackAndEmitEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.playlist.tracks.findIndex((x) => x.id === uuid);
|
return uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueTrackAndInstantyPlay(track: Track) {
|
||||||
|
const uuid = uuidv4();
|
||||||
|
|
||||||
|
this.playlist.tracks.push({
|
||||||
|
id: uuid,
|
||||||
|
track: track,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setActiveTrack(uuid);
|
||||||
|
this.getActiveTrackAndEmitEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
set(tracks: Track[]) {
|
set(tracks: Track[]) {
|
||||||
@ -121,7 +133,7 @@ export class PlaybackService {
|
|||||||
return this.getTrackIds().indexOf(this.playlist.activeTrack);
|
return this.getTrackIds().indexOf(this.playlist.activeTrack);
|
||||||
}
|
}
|
||||||
|
|
||||||
private controlAudioPlayer() {
|
getActiveTrackAndEmitEvent() {
|
||||||
const activeTrack = this.getActiveTrack();
|
const activeTrack = this.getActiveTrack();
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`A new track (${activeTrack.id}) was requested and will be emmitted as an event`,
|
`A new track (${activeTrack.id}) was requested and will be emmitted as an event`,
|
||||||
|
60
src/types/websocket.ts
Normal file
60
src/types/websocket.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { PlaystateCommand } from '@jellyfin/sdk/lib/generated-client/models';
|
||||||
|
|
||||||
|
export class PlayNowCommand {
|
||||||
|
/**
|
||||||
|
* A list of all items available in the parent element.
|
||||||
|
* Usually, this is a list of all tracks in an album or playlist.
|
||||||
|
*/
|
||||||
|
ItemIds: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A nullable index, that references an item in the ItemIds array.
|
||||||
|
* If this index is present, the command sender wishes to play only this specific item.
|
||||||
|
* If there is no index present, the sender would like to play all items in the ItemIds array.
|
||||||
|
*/
|
||||||
|
StartIndex?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An enum of possible play modes.
|
||||||
|
* PlayNow: Play the selection immideatly
|
||||||
|
*/
|
||||||
|
PlayCommand: 'PlayNow';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user who has sent the command via web socket
|
||||||
|
*/
|
||||||
|
ControllingUserId: string;
|
||||||
|
|
||||||
|
hasSelection() {
|
||||||
|
return this.StartIndex !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSelection(): string[] {
|
||||||
|
if (this.hasSelection()) {
|
||||||
|
return [this.ItemIds[this.StartIndex]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.ItemIds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionApiSendPlaystateCommandRequest {
|
||||||
|
/**
|
||||||
|
* The MediaBrowser.Model.Session.PlaystateCommand.
|
||||||
|
* @type {PlaystateCommand}
|
||||||
|
* @memberof SessionApiSendPlaystateCommand
|
||||||
|
*/
|
||||||
|
readonly Command: PlaystateCommand;
|
||||||
|
/**
|
||||||
|
* The optional position ticks.
|
||||||
|
* @type {number}
|
||||||
|
* @memberof SessionApiSendPlaystateCommand
|
||||||
|
*/
|
||||||
|
readonly SeekPositionTicks?: number;
|
||||||
|
/**
|
||||||
|
* The optional controlling user id.
|
||||||
|
* @type {string}
|
||||||
|
* @memberof SessionApiSendPlaystateCommand
|
||||||
|
*/
|
||||||
|
readonly ControllingUserId?: string;
|
||||||
|
}
|
@ -3,7 +3,7 @@ export const Constants = {
|
|||||||
Version: {
|
Version: {
|
||||||
Major: 0,
|
Major: 0,
|
||||||
Minor: 0,
|
Minor: 0,
|
||||||
Patch: 2,
|
Patch: 3,
|
||||||
All: () =>
|
All: () =>
|
||||||
`${Constants.Metadata.Version.Major}.${Constants.Metadata.Version.Minor}.${Constants.Metadata.Version.Patch}`,
|
`${Constants.Metadata.Version.Major}.${Constants.Metadata.Version.Minor}.${Constants.Metadata.Version.Patch}`,
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user