Add report capabilities and session api (#40)

This commit is contained in:
Manuel 2022-12-27 14:22:05 +01:00 committed by GitHub
parent 64fe436476
commit 80e138900c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 7823 additions and 5248 deletions

View File

@ -39,7 +39,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",

View File

@ -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,

View File

@ -16,6 +16,7 @@ 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,7 @@ 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,
) {} ) {}
@OnEvent('playback.newTrack') @OnEvent('playback.newTrack')
@ -74,6 +76,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);
} }

View File

@ -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 {

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

View File

@ -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 {
@ -14,7 +15,10 @@ export class JellyfinService {
private systemApi: SystemApi; private systemApi: SystemApi;
private userId: string; private userId: string;
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 +42,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}'`,
@ -53,7 +57,7 @@ export class JellyfinService {
this.systemApi = getSystemApi(this.api); this.systemApi = getSystemApi(this.api);
this.eventEmitter.emit('clients.jellyfin.ready'); await this.jellyfinPlayState.initializePlayState(this.api);
}) })
.catch((test) => { .catch((test) => {
this.logger.error(test); this.logger.error(test);

View File

@ -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 { JellyfinService } from './jellyfin.service';
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api/playstate-api'; import { SessionMessageType } from '@jellyfin/sdk/lib/generated-client/models';
import { OnEvent } from '@nestjs/event-emitter'; 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() @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,
) {}
@Cron('*/30 * * * * *')
private handlePeriodicAliveMessage() {
if (
this.webSocket === undefined ||
this.webSocket.readyState !== WebSocket.OPEN
) {
return;
} }
private async openSocket() { this.sendMessage('KeepAlive');
const systemApi = getPlaystateApi(this.jellyfinClientManager.getApi()); this.logger.debug('Sent a KeepAlive package to the server');
}
// TODO: Write socket playstate api to report playback progress initializeAndConnect() {
const deviceId = this.jellyfinService.getJellyfin().deviceInfo.id;
const url = this.buildSocketUrl(
this.jellyfinService.getApi().basePath,
this.jellyfinService.getApi().accessToken,
deviceId,
);
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;
}

View File

@ -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.",
}), }),
], ],
}; };

View File

@ -86,6 +86,18 @@ export class PlaybackService {
return this.playlist.tracks.findIndex((x) => x.id === uuid); 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[]) { set(tracks: Track[]) {
this.playlist.tracks = tracks.map((t) => ({ this.playlist.tracks = tracks.map((t) => ({
id: uuidv4(), id: uuidv4(),

12816
yarn.lock

File diff suppressed because it is too large Load Diff