mirror of
https://github.com/informaticker/discord-jellyfin-bot.git
synced 2024-11-25 02:51:57 +01:00
✨ Add report capabilities and session api (#40)
This commit is contained in:
parent
64fe436476
commit
80e138900c
@ -39,7 +39,8 @@
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.2",
|
||||
"rxjs": "^7.2.0",
|
||||
"uuid": "^9.0.0"
|
||||
"uuid": "^9.0.0",
|
||||
"ws": "^8.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^9.0.0",
|
||||
|
@ -3,12 +3,13 @@ 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 { DiscordConfigService } from './discord.config.service';
|
||||
import { DiscordMessageService } from './discord.message.service';
|
||||
import { DiscordVoiceService } from './discord.voice.service';
|
||||
|
||||
@Module({
|
||||
imports: [PlaybackModule],
|
||||
imports: [PlaybackModule, JellyfinClientModule],
|
||||
controllers: [],
|
||||
providers: [
|
||||
DiscordConfigService,
|
||||
|
@ -16,6 +16,7 @@ import { GuildMember } from 'discord.js';
|
||||
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 { DiscordMessageService } from './discord.message.service';
|
||||
|
||||
@Injectable()
|
||||
@ -27,6 +28,7 @@ export class DiscordVoiceService {
|
||||
constructor(
|
||||
private readonly discordMessageService: DiscordMessageService,
|
||||
private readonly playbackService: PlaybackService,
|
||||
private readonly jellyfinWebSocketService: JellyfinWebSocketService,
|
||||
) {}
|
||||
|
||||
@OnEvent('playback.newTrack')
|
||||
@ -74,6 +76,8 @@ export class DiscordVoiceService {
|
||||
guildId: channel.guildId,
|
||||
});
|
||||
|
||||
this.jellyfinWebSocketService.initializeAndConnect();
|
||||
|
||||
if (this.voiceConnection == undefined) {
|
||||
this.voiceConnection = getVoiceConnection(member.guild.id);
|
||||
}
|
||||
|
@ -2,14 +2,17 @@ import { Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { JellyfinSearchService } from './jellyfin.search.service';
|
||||
import { JellyfinService } from './jellyfin.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({
|
||||
imports: [],
|
||||
imports: [PlaybackModule],
|
||||
controllers: [],
|
||||
providers: [
|
||||
JellyfinService,
|
||||
JellyinWebsocketService,
|
||||
JellyinPlaystateService,
|
||||
JellyfinWebSocketService,
|
||||
JellyfinSearchService,
|
||||
JellyfinStreamBuilderService,
|
||||
],
|
||||
@ -17,6 +20,7 @@ import { JellyinWebsocketService } from './jellyfin.websocket.service';
|
||||
JellyfinService,
|
||||
JellyfinSearchService,
|
||||
JellyfinStreamBuilderService,
|
||||
JellyfinWebSocketService,
|
||||
],
|
||||
})
|
||||
export class JellyfinClientModule implements OnModuleInit, OnModuleDestroy {
|
||||
|
43
src/clients/jellyfin/jellyfin.playstate.service.ts
Normal file
43
src/clients/jellyfin/jellyfin.playstate.service.ts
Normal file
@ -0,0 +1,43 @@
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class JellyinPlaystateService {
|
||||
private playstateApi: PlaystateApi;
|
||||
private sessionApi: SessionApi;
|
||||
|
||||
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.SetRepeatMode,
|
||||
GeneralCommandType.Play,
|
||||
GeneralCommandType.PlayState,
|
||||
],
|
||||
});
|
||||
|
||||
this.logger.debug('Reported playback capabilities sucessfully');
|
||||
}
|
||||
}
|
@ -1,10 +1,11 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { Api, Jellyfin } from '@jellyfin/sdk';
|
||||
import { Constants } from '../../utils/constants';
|
||||
import { SystemApi } from '@jellyfin/sdk/lib/generated-client/api/system-api';
|
||||
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Constants } from '../../utils/constants';
|
||||
import { JellyinPlaystateService } from './jellyfin.playstate.service';
|
||||
|
||||
@Injectable()
|
||||
export class JellyfinService {
|
||||
@ -14,7 +15,10 @@ export class JellyfinService {
|
||||
private systemApi: SystemApi;
|
||||
private userId: string;
|
||||
|
||||
constructor(private readonly eventEmitter: EventEmitter2) {}
|
||||
constructor(
|
||||
private eventEmitter: EventEmitter2,
|
||||
private readonly jellyfinPlayState: JellyinPlaystateService,
|
||||
) {}
|
||||
|
||||
init() {
|
||||
this.jellyfin = new Jellyfin({
|
||||
@ -38,7 +42,7 @@ export class JellyfinService {
|
||||
process.env.JELLYFIN_AUTHENTICATION_USERNAME,
|
||||
process.env.JELLYFIN_AUTHENTICATION_PASSWORD,
|
||||
)
|
||||
.then((response) => {
|
||||
.then(async (response) => {
|
||||
if (response.data.SessionInfo === undefined) {
|
||||
this.logger.error(
|
||||
`Failed to authenticate with response code ${response.status}: '${response.data}'`,
|
||||
@ -53,7 +57,7 @@ export class JellyfinService {
|
||||
|
||||
this.systemApi = getSystemApi(this.api);
|
||||
|
||||
this.eventEmitter.emit('clients.jellyfin.ready');
|
||||
await this.jellyfinPlayState.initializePlayState(this.api);
|
||||
})
|
||||
.catch((test) => {
|
||||
this.logger.error(test);
|
||||
|
@ -1,23 +1,165 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { JellyfinService } from './jellyfin.service';
|
||||
|
||||
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api/playstate-api';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { 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';
|
||||
|
||||
@Injectable()
|
||||
export class JellyinWebsocketService {
|
||||
constructor(private readonly jellyfinClientManager: JellyfinService) {}
|
||||
export class JellyfinWebSocketService implements OnModuleDestroy {
|
||||
private webSocket: WebSocket;
|
||||
|
||||
@OnEvent('clients.jellyfin.ready')
|
||||
handleJellyfinBotReady() {
|
||||
console.log('ready!');
|
||||
private readonly logger = new Logger(JellyfinWebSocketService.name);
|
||||
|
||||
this.openSocket();
|
||||
constructor(
|
||||
private readonly jellyfinService: JellyfinService,
|
||||
private readonly jellyfinSearchService: JellyfinSearchService,
|
||||
private readonly playbackService: PlaybackService,
|
||||
private readonly jellyfinStreamBuilderService: JellyfinStreamBuilderService,
|
||||
) {}
|
||||
|
||||
@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() {
|
||||
const systemApi = getPlaystateApi(this.jellyfinClientManager.getApi());
|
||||
initializeAndConnect() {
|
||||
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 { ItemIds: string[]; StartIndex: number };
|
||||
const ids = data.ItemIds;
|
||||
|
||||
this.jellyfinSearchService
|
||||
.getById(ids[data.StartIndex])
|
||||
.then((response) => {
|
||||
const track: Track = {
|
||||
name: response.Name,
|
||||
durationInMilliseconds: response.RunTimeTicks / 1000,
|
||||
jellyfinId: response.Id,
|
||||
streamUrl: this.jellyfinStreamBuilderService.buildStreamUrl(
|
||||
response.Id,
|
||||
96000,
|
||||
),
|
||||
remoteImages: {
|
||||
Images: [],
|
||||
Providers: [],
|
||||
TotalRecordCount: 0,
|
||||
},
|
||||
};
|
||||
this.playbackService.enqueTrackAndInstantyPlay(track);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
this.logger.warn(
|
||||
`Received a package from the socket of unknown type: ${msg.MessageType}`,
|
||||
);
|
||||
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: [
|
||||
this.discordMessageService.buildMessage({
|
||||
title: 'Joined your voicehannel',
|
||||
description:
|
||||
"I'm ready to play media. Use ``Cast to device`` in Jellyfin or the ``/play`` command to get started.",
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
@ -86,6 +86,18 @@ export class PlaybackService {
|
||||
return this.playlist.tracks.findIndex((x) => x.id === uuid);
|
||||
}
|
||||
|
||||
enqueTrackAndInstantyPlay(track: Track) {
|
||||
const uuid = uuidv4();
|
||||
|
||||
this.playlist.tracks.push({
|
||||
id: uuid,
|
||||
track: track,
|
||||
});
|
||||
|
||||
this.setActiveTrack(uuid);
|
||||
this.controlAudioPlayer();
|
||||
}
|
||||
|
||||
set(tracks: Track[]) {
|
||||
this.playlist.tracks = tracks.map((t) => ({
|
||||
id: uuidv4(),
|
||||
|
Loading…
Reference in New Issue
Block a user