♻️ Migrate to NestJS

This commit is contained in:
Manuel Ruwe 2022-12-15 23:57:55 +01:00
parent cc6fd1e2d4
commit 6c5b282bc0
40 changed files with 12070 additions and 4078 deletions

25
.eslintrc.js Normal file
View File

@ -0,0 +1,25 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir : __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};

43
.gitignore vendored
View File

@ -1,4 +1,39 @@
node_modules
package-lock.json
config.json
.prettierrc
# compiled output
/dist
/node_modules
.yarn
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# Secrets
*.env

4
.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

1
.yarnrc.yml Normal file
View File

@ -0,0 +1 @@
nodeLinker: node-modules

160
README.md
View File

@ -1,145 +1,63 @@
<p align="center">
<img src="https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/jellyfin.png" alt="Jellyfin Logo" width="80" height="80">
<h1 align="center">Jellyfin Discord Music Bot</h1>
<div align="center">
<span>A fork of the <a href="https://github.com/KGT1/jellyfin-discord-music-bot">original project</a> with improved readability and stability, compatible with Jellyfin 10.8.x</span>
</div>
<a href="http://nestjs.com/" target="blank"><img src="https://github.com/walkxcode/dashboard-icons/blob/main/png/jellyfin.png?raw=true" width="200" alt="Nest Logo" /></a>
</p>
# ✨ Features
- Simple Discord Bot that hooks into the [Jellyfin](http://github.com/jellyfin/jellyfin) API of your instance
- Request, pause and play songs directly from your Discord Server
- Interactive Media control message to control playback
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
# 🦾 About this fork
The original version is decent for Jellyfin 10.6.x and before. After the breaking changes of 10.7 and 10.8, users were unable to stream music from their Jellyfin.
For this reason, I made this fork to address those changes to the API and improve the bot with my own ideas / features. Please check out the original project by [KGT1](https://github.com/KGT1).
<p align="center">A simple <a href="https://discord.com" target="_blank">Discord</a> bot that enables you to broadcast your <a href="https://jellyfin.org/" target="_blank">Jellyfin Media Server</a> music collection to voice channels.</p>
I will gradually update documentation & code of the bot. Please wait patiently.
<p align="center">
<small>Thanky you KGT1 for starting this project! This is a fork of their original repository and re-uses some of their code.</small>
</p>
<br/><br/><br/><br/><br/><br/><hr/><br/>
## Original README from https://github.com/KGT1/jellyfin-discord-music-bot
<br/>
<br/>
<hr/>
<br/>
Jellyfin Discord Music Bot is a Discord Bot for the [Jellyfin Media Server!](http://github.com/jellyfin/jellyfin)
### Capabilities
## ✨ Features
#### Play to
- Leighweight and extendable using the [Nest](https://github.com/nestjs/nest) framework
- Easy ussage with Discord commands system (eg. ``/play``, ``/pause``, ...)
- Fast configuration via environment variables
- Typesafe for faster and easier development
Just `summon` the Bot into your Channel, than choose the Bot in Jellyfin as the Device you want to cast to
![Image to Discord Play to Window](img/playtowindow.png)
and start playing you favourite Music
#### Interactive Play Message
When you start playing something you can easily controll the Bot with just clicking on the Buttons under the Play Message
![Image to Interactive Play Message](img/discordplaymessage.png)
#### Commands
Beware that you'll always need to add your prefix(default: ?) in front of the command.
Command | Description
------------ | -------------
summon | Join the channel the author of the message(now you can cast to the Bot from within Jellyfin)
disconnect | Disconnect from all current Voice Channels
play | Play the following item(can be the name of the song or the Stream URL)
add | Add the following item to the current playlist
pause/resume | Pause/Resume audio
seek | Where to Seek to in seconds or MM:SS
skip | Skip this Song
spawn | Spawns an Interactive Play Controller
help | Display the help message
#### Limitations
- No Playlist Repeat Mode.
- Multi Server support.
- [Playing Video Content](https://github.com/discordjs/discord.js/issues/4116) (if Discord ever adds this, I'll implement it into this Bot)
### Getting Started
You'll need a Discord Application for this Bot to work, as you will host it yourself.
[Generate an Api and bot here](https://discord.com/developers/applications/).
Click New Application.
![image](https://user-images.githubusercontent.com/20715731/97124506-bba00080-1706-11eb-820a-035039484ca2.png)
The Name of the application will be the bot's name.
![image](https://user-images.githubusercontent.com/20715731/97124528-d2deee00-1706-11eb-8a05-8b0542e1213a.png)
Go to the Bot tab.
![image](https://user-images.githubusercontent.com/20715731/97124557-ef7b2600-1706-11eb-8fed-2373df9a1eb7.png)
Generate the bot, and grab the token. Also, recommend making the bot private.
![image](https://user-images.githubusercontent.com/20715731/97124639-484abe80-1707-11eb-92f9-1182aad3d2d2.png)
Go to the OAuth2 page, click Bot Scope to get the url authorization link.
![image](https://user-images.githubusercontent.com/20715731/97124754-b68f8100-1707-11eb-9e16-f84401d108bf.png)
Authorize your room!
![image](https://user-images.githubusercontent.com/20715731/97124818-08380b80-1708-11eb-944a-f96395dcf6c1.png)
Next, join a voice channel and connect your bot with ?summon. This will connect your bot to the voice channel you're in and will create the device profile in Jellyfin.
![Image to Discord Play to Window](img/playtowindow.png)
From within Jellyfin, start playing content or from within Discord, use the bot commands to start enjoying music!
For official documentation to creating a bot.
[How to retrieve your token](https://discordjs.guide/preparations/setting-up-a-bot-application.html#creating-your-bot)
[How to invite the Bot to your server](https://discordjs.guide/preparations/adding-your-bot-to-servers.html#bot-invite-links)
### The simplest way to get started is using Docker:
## 🚀 Installation
```bash
docker run -d \
--name jellyfin-discord-music-bot \
-e DISCORD_PREFIX="?" \
-e DISCORD_TOKEN="yourtokengoeshere" \
-e JELLYFIN_SERVER_ADDRESS="https://jellyfin.DOMAIN" \
-e JELLYFIN_USERNAME="" \
-e JELLYFIN_PASSWORD="" \
-e JELLYFIN_APP_NAME="Jellyfin Discord Music Bot" \
-e MESSAGE_UPDATE_INTERVAL="2000" \
--restart unless-stopped \
kgt1/jellyfin-discord-music-bot
$ git clone https://github.com/manuel-rw/jellyfin-discord-music-bot.git
$ cd jellyfin-discord-music-bot/
$ yarn
$ yarn start:prod
```
MESSAGE_UPDATE_INTERVAL is the amount of time in ms the play message gets updated with the current time
> Docker container comming soon
#### Alternatively you can run the Application natively with NodeJS:
## 💻 Development
Dependencies:
I'm open to any contributions to this project. You can start contributing using the following commands, after executing the installation commands:
- npm 6.14.6
- NodeJS v12.18.3
- ffmpeg 4.2.4
```bash
git clone https://github.com/kgt1/jellyfin-discord-music-bot.git
cd jellyfin-discord-music-bot
npm install
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
edit config.json and add your token,server-address etc.
```bash
npm run start
```
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
### How to build
```
git clone https://github.com/kgt1/jellyfin-discord-music-bot.git
cd jellyfin-discord-music-bot
docker build -t YOUR_IMAGE_NAME .
# test coverage
$ npm run test:cov
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

5
nest-cli.json Normal file
View File

@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}

4088
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,48 +1,78 @@
{
"name": "jellyfin-discord-music-bot",
"version": "0.0.1",
"description": "Jellyfin Discord Music Bot is a Discord Bot for the Jellyfin Media Server!",
"main": "src/index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node src/index.js",
"postinstall": "npx patch-package",
"lint": "npx eslint src/ & npx eslint parseENV.js"
},
"repository": {
"type": "git",
"url": "git+https://github.com/KGT1/jellyfin-discord-music-bot.git"
},
"keywords": [
"Jellyfin",
"Discord",
"Discord-Bot"
],
"author": "KGT1",
"description": "",
"author": "manuel-rw",
"private": true,
"license": "MIT",
"bugs": {
"url": "https://github.com/KGT1/jellyfin-discord-music-bot/issues"
"scripts": {
"prebuild": "rimraf dist",
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"homepage": "https://github.com/KGT1/jellyfin-discord-music-bot#readme",
"dependencies": {
"@discordjs/opus": "^0.3.2",
"chalk": "^4.1.0",
"discord.js": "^12.3.1",
"jellyfin-apiclient": "1.7.0",
"loglevel": "^1.7.1",
"loglevel-plugin-prefix": "^0.8.4",
"node-fetch": "^2.6.0",
"nodejs": "0.0.0",
"window": "^4.2.7",
"ws": "^7.3.1"
"@discordjs/opus": "^0.9.0",
"@jellyfin/sdk": "^0.7.0",
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.0.0",
"@nestjs/event-emitter": "^1.3.1",
"@nestjs/platform-express": "^9.0.0",
"discord.js": "^14.7.1",
"jellyfin-apiclient": "^1.10.0",
"joi": "^17.7.0",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0"
},
"devDependencies": {
"patch-package": "^6.4.7",
"eslint": "^7.9.0",
"eslint-config-standard": "^14.1.1",
"eslint-plugin-import": "^2.22.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-standard": "^4.0.1"
"@nestjs/cli": "^9.0.0",
"@nestjs/schematics": "^9.0.0",
"@nestjs/testing": "^9.0.0",
"@types/express": "^4.17.13",
"@types/jest": "28.1.8",
"@types/node": "^16.0.0",
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
"eslint": "^8.0.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"jest": "28.1.3",
"prettier": "^2.3.2",
"source-map-support": "^0.5.20",
"supertest": "^6.1.3",
"ts-jest": "28.0.8",
"ts-loader": "^9.2.3",
"ts-node": "^10.0.0",
"tsconfig-paths": "4.1.0",
"typescript": "^4.7.4"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

File diff suppressed because one or more lines are too long

View File

@ -1,217 +0,0 @@
const discordclientmanager = require("./discordclientmanager");
const CONFIG = require("../config.json");
const { secondsToHms, ticksToSeconds } = require("./util");
const log = require("loglevel");
const getProgressString = (percent) => {
// the min with of the discord window allows for this many chars
const NUMBER_OF_CHARS = 12;
let string = "";
for (let iX = 0; iX < NUMBER_OF_CHARS; iX++) {
if (percent > iX / NUMBER_OF_CHARS) {
string += "█";
} else {
string += "▒";
}
}
return string;
};
/**
*
* @param {String} string
* @returns {String}
*/
// TODO do this with something like wcwidth
function getMaxWidthString (string) {
const NUMBER_OF_CHARS = 12;
if (string.length > NUMBER_OF_CHARS) {
return string.slice(0, NUMBER_OF_CHARS - 3) + "...";
}
return string;
};
class InterActivePlayMessage {
// musicplayermessage
// probably should have done events instead of callbacks
/**
*
* @param {Object} message
* @param {String} title
* @param {String} artist
* @param {String} imageURL
* @param {String} itemURL
* @param {Number} ticksLength
* @param {Function} onPrevious
* @param {Function} onPausePlay
* @param {Function} onStop
* @param {Function} onNext
* @param {Function} onRepeat
*/
constructor(
message,
title,
artist,
imageURL,
itemURL,
ticksLength,
onPrevious,
onPausePlay,
onStop,
onNext,
onRepeat,
playlistLenth,
) {
this.ticksLength = ticksLength;
var exampleEmbed = {
color: 0x0099ff,
title: "Now Playing",
url: itemURL,
description: `\`\`${getMaxWidthString(title)}\`\` by \`\`${getMaxWidthString(
artist,
)}\`\``,
thumbnail: {
url: imageURL,
},
fields: [],
timestamp: new Date(),
};
if (typeof CONFIG["interactive-seek-bar-update-intervall"] === "number") {
exampleEmbed.fields.push({
name: getProgressString(0 / this.ticksLength),
value: `${secondsToHms(0)} / ${secondsToHms(
ticksToSeconds(this.ticksLength),
)}`,
inline: false,
});
}
if (playlistLenth) {
exampleEmbed.fields.push({
name: `1 of ${playlistLenth}`,
value: "Playlist",
inline: false,
});
}
message.channel
.send({
embed: exampleEmbed,
})
.then((val) => {
this.musicplayermessage = val;
val.react("⏮️");
val.react("⏯️");
val.react("⏹️");
val.react("⏭️");
val.react("🔁");
})
.catch(console.error);
function reactionchange(reaction, user, musicplayermessage) {
if (reaction.message.id === musicplayermessage.id && !user.bot) {
try {
switch (reaction._emoji.name) {
case "⏮️":
onPrevious();
break;
case "⏯️":
onPausePlay();
break;
case "⏹️":
onStop();
break;
case "⏭️":
onNext();
break;
case "🔁":
onRepeat();
break;
default:
break;
}
} catch (error) {}
}
}
discordclientmanager
.getDiscordClient()
.on("messageReactionAdd", (reaction, user) => {
reactionchange(reaction, user, this.musicplayermessage);
});
discordclientmanager
.getDiscordClient()
.on("messageReactionRemove", (reaction, user) => {
reactionchange(reaction, user, this.musicplayermessage);
});
}
updateProgress(ticks) {
if (
typeof this.musicplayermessage !== "undefined" &&
typeof this.musicplayermessage.embeds[0] !== "undefined" &&
typeof this.musicplayermessage.embeds[0].fields[0] !== "undefined"
) {
this.musicplayermessage.embeds[0].fields[0] = {
name: getProgressString(ticks / this.ticksLength),
value: `${secondsToHms(ticksToSeconds(ticks))} / ${secondsToHms(
ticksToSeconds(this.ticksLength),
)}`,
inline: false,
};
this.musicplayermessage.timestamp = new Date();
this.musicplayermessage.edit(this.musicplayermessage.embeds[0]);
}
}
updateCurrentSongMessage(
title,
artist,
imageURL,
itemURL,
ticksLength,
playlistIndex,
playlistLenth,
) {
if (!this.musicplayermessage) {
log.error("Interactive play message was not found");
return;
}
if (this.musicplayermessage.embeds.length === 0) {
log.error("Interactive play message was unable to access embeds");
return;
}
this.musicplayermessage.embeds[0].url = itemURL;
this.musicplayermessage.embeds[0].description = `\`\`${getMaxWidthString(
title,
)}\`\` by \`\`${getMaxWidthString(artist)}\`\``;
this.musicplayermessage.embeds[0].thumbnail = { url: imageURL };
const indexOfPlaylistMessage =
this.musicplayermessage.embeds[0].fields.findIndex((element) => {
return element.value === "Playlist";
});
if (indexOfPlaylistMessage === -1) {
this.musicplayermessage.embeds[0].fields.push({
name: `${playlistIndex} of ${playlistLenth}`,
value: "Playlist",
inline: false,
});
} else {
this.musicplayermessage.embeds[0].fields[
indexOfPlaylistMessage
].name = `${playlistIndex} of ${playlistLenth}`;
}
this.ticksLength = ticksLength;
this.musicplayermessage.timestamp = new Date();
this.musicplayermessage.edit(this.musicplayermessage.embeds[0]);
}
destroy() {
this.musicplayermessage.delete();
delete this;
}
}
module.exports = InterActivePlayMessage;

View File

@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

12
src/app.controller.ts Normal file
View File

@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

31
src/app.module.ts Normal file
View File

@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import * as Joi from 'joi';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { DiscordClientModule } from './clients/discord/discord.module';
import { CommandHandlerModule } from './commands/handler/command-handler.module';
import { JellyfinClientModule } from './clients/jellyfin/jellyfin.module';
@Module({
imports: [
ConfigModule.forRoot({
validationSchema: Joi.object({
DISCORD_CLIENT_TOKEN: Joi.string().required(),
JELLYFIN_SERVER_ADDRESS: Joi.string().required(),
JELLYFIN_AUTHENTICATION_USERNAME: Joi.string().required(),
JELLYFIN_AUTHENTICATION_PASSWORD: Joi.string().required(),
}),
}),
EventEmitterModule.forRoot(),
DiscordClientModule,
JellyfinClientModule,
CommandHandlerModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

9
src/app.service.ts Normal file
View File

@ -0,0 +1,9 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
constructor() {}
getHello(): string {
return 'Hello World!';
}
}

View File

@ -0,0 +1,22 @@
import { Module, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { DiscordService } from "./discord.service";
@Module({
imports: [],
controllers: [],
providers: [DiscordService],
exports: [DiscordService],
})
export class DiscordClientModule implements OnModuleInit, OnModuleDestroy {
constructor(private discordService: DiscordService) {}
onModuleDestroy() {
this.discordService.destroyClient();
}
onModuleInit() {
this.discordService.initializeClient();
this.discordService.registerEventHandlers();
this.discordService.connectAndLogin();
}
}

View File

@ -0,0 +1,46 @@
import { Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { ActivityType, Client } from 'discord.js';
@Injectable()
export class DiscordService {
private readonly logger = new Logger(DiscordService.name);
private client: Client;
constructor(private eventEmitter: EventEmitter2) {}
initializeClient() {
this.client = new Client({
intents: ['Guilds', 'GuildMessages', 'MessageContent'],
});
this.logger.debug('Initialized Discord client');
}
connectAndLogin() {
this.client.login(process.env.DISCORD_CLIENT_TOKEN);
}
registerEventHandlers() {
this.client.on('ready', () => {
this.logger.debug(`Connected as '${this.client.user.tag}' and ready!`);
this.eventEmitter.emit('client.discord.ready');
});
this.client.on('messageCreate', async (message) => {
if (message.author.bot) {
return;
}
await message.channel.send('nice');
});
}
destroyClient() {
this.client.destroy();
}
getClient() {
return this.client;
}
}

View File

@ -0,0 +1,22 @@
import { Module, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { JellyfinService } from "./jellyfin.service";
@Module({
imports: [],
controllers: [],
providers: [JellyfinService],
exports: [],
})
export class JellyfinClientModule implements OnModuleInit, OnModuleDestroy {
constructor(private jellyfinService: JellyfinService) {}
onModuleDestroy() {
this.jellyfinService.destroy();
}
onModuleInit() {
this.jellyfinService.init();
this.jellyfinService.authenticate();
}
}

View File

@ -0,0 +1,55 @@
import { Injectable, Logger } from '@nestjs/common';
import { Api, Jellyfin } from '@jellyfin/sdk';
import { Constants } from 'src/utils/constants';
@Injectable()
export class JellyfinService {
private readonly logger = new Logger(JellyfinService.name);
private jellyfin: Jellyfin;
private api: Api;
constructor() {}
init() {
this.jellyfin = new Jellyfin({
clientInfo: {
name: Constants.Metadata.ApplicationName,
version: Constants.Metadata.Version,
},
deviceInfo: {
id: 'test',
name: 'test',
},
});
this.api = this.jellyfin.createApi(process.env.JELLYFIN_SERVER_ADDRESS);
this.logger.debug('Created Jellyfin Client and Api');
}
authenticate() {
this.api
.authenticateUserByName(
process.env.JELLYFIN_AUTHENTICATION_USERNAME,
process.env.JELLYFIN_AUTHENTICATION_PASSWORD,
)
.then((response) => {
this.logger.debug(
`Connected using user '${response.data.SessionInfo.UserId}'`,
);
}).catch((test) => {
this.logger.error(test);
});
}
destroy() {
if (this.api === undefined) {
this.logger.warn(
'Jellyfin Api Client was unexpectitly undefined. Graceful destroy has failed',
);
return;
}
this.api.logout();
}
}

View File

@ -0,0 +1,6 @@
import { SlashCommandBuilder } from "discord.js";
export abstract class Command {
abstract builder(): SlashCommandBuilder;
abstract execute(): void;
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { DiscordClientModule } from '../../clients/discord/discord.module';
import { CommandHandlerService } from './command-handler.service';
@Module({
imports: [DiscordClientModule],
controllers: [],
providers: [CommandHandlerService],
})
export class CommandHandlerModule {}

View File

@ -0,0 +1,107 @@
import { EmbedBuilder } from '@discordjs/builders';
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import {
ApplicationCommand,
SlashCommandBuilder,
SlashCommandSubcommandBuilder,
} from 'discord.js';
import { DiscordService } from 'src/clients/discord/discord.service';
import { Command } from '../abstractCommand';
@Injectable()
export class CommandHandlerService {
private logger: Logger = new Logger(CommandHandlerService.name);
constructor(private discordService: DiscordService) {}
@OnEvent('client.discord.ready')
async handleOnDiscordClientReady() {
var commands = [
new SlashCommandBuilder()
.setName('play')
.setDescription('Immideatly play a track')
.addStringOption((option) =>
option
.setName('track')
.setDescription('the track name')
.setRequired(true),
),
new SlashCommandBuilder()
.setName('summon')
.setDescription('Join your current voice channel'),
new SlashCommandBuilder()
.setName('disconnect')
.setDescription('Disconnect from the current voice channel'),
new SlashCommandBuilder()
.setName('enqueue')
.setDescription('Enqueue a track to the current playlist')
.addStringOption((option) =>
option
.setName('track')
.setDescription('the track name')
.setRequired(true),
),
new SlashCommandBuilder()
.setName('current')
.setDescription('Print the current track information'),
new SlashCommandBuilder()
.setName('pause')
.setDescription('Pause or resume the playback of the current track'),
new SlashCommandBuilder()
.setName('skip')
.setDescription('Skip the current track'),
new SlashCommandBuilder()
.setName('stop')
.setDescription(
'Stop playback entirely and clear the current playlist',
),
new SlashCommandBuilder()
.setName('help')
.setDescription('Get help for this Discord Bot'),
];
await this.discordService
.getClient()
.application.commands.set(commands.map((x) => x.toJSON()));
this.discordService
.getClient()
.on('interactionCreate', async (interaction) => {
if (!interaction.isChatInputCommand()) {
return;
}
await interaction.reply({
embeds: [
new EmbedBuilder()
.setAuthor({
name: 'Jellyfin Discord Bot',
iconURL:
'https://github.com/walkxcode/dashboard-icons/blob/main/png/jellyfin.png?raw=true',
url: 'https://github.com/manuel-rw/jellyfin-discord-music-bot',
})
.setTitle('Help Information')
.setDescription(
'Jellyfin Discord Music bot is an easy way to broadcast your music collection to a Discord voicechannel.',
)
.addFields([
{
name: 'Report an issue',
value:
'https://github.com/manuel-rw/jellyfin-discord-music-bot/issues/new/choose',
inline: true,
},
{
name: 'Source code',
value:
'https://github.com/manuel-rw/jellyfin-discord-music-bot',
inline: true,
},
])
.toJSON(),
],
});
});
}
}

View File

@ -1,15 +0,0 @@
const Discord = require("discord.js");
var discordClient;
function init() {
discordClient = new Discord.Client();
}
function getDiscordClient() {
return discordClient;
}
module.exports = {
getDiscordClient,
init,
};

View File

@ -1,13 +0,0 @@
var audioDispatcher;
function setAudioDispatcher(par) {
audioDispatcher = par;
}
function getAudioDispatcher() {
return audioDispatcher;
}
module.exports = {
setAudioDispatcher,
getAudioDispatcher,
};

View File

@ -1,64 +0,0 @@
const log = require("loglevel");
try {
const CONFIG = require("../config.json");
const jellyfinClientManager = require("./jellyfinclientmanager");
const discordclientmanager = require("./discordclientmanager");
discordclientmanager.init();
const discordClient = discordclientmanager.getDiscordClient();
const { handleChannelMessage } = require("./messagehandler");
const prefix = require("loglevel-plugin-prefix");
const chalk = require("chalk");
const colors = {
TRACE: chalk.magenta,
DEBUG: chalk.cyan,
INFO: chalk.blue,
WARN: chalk.yellow,
ERROR: chalk.red,
};
log.setLevel(CONFIG["log-level"]);
prefix.reg(log);
log.enableAll();
prefix.apply(log, {
format(level, name, timestamp) {
return `${chalk.gray(`[${timestamp}]`)} ${colors[level.toUpperCase()](
level
)} ${chalk.green(`${name}:`)}`;
},
});
prefix.apply(log.getLogger("critical"), {
format(level, name, timestamp) {
return chalk.red.bold(`[${timestamp}] ${level} ${name}:`);
},
});
jellyfinClientManager.init();
// TODO Error Checking as the apiclients is inefficent
jellyfinClientManager
.getJellyfinClient()
.authenticateUserByName(
CONFIG["jellyfin-username"],
CONFIG["jellyfin-password"]
)
.then((response) => {
jellyfinClientManager
.getJellyfinClient()
.setAuthenticationInfo(response.AccessToken, response.SessionInfo.UserId);
});
discordClient.on("message", (message) => {
handleChannelMessage(message);
});
discordClient.login(CONFIG.token);
} catch (error) {
log.error(error);
console.error(error);
}

View File

@ -1,112 +0,0 @@
const InterActivePlayMessage = require("./InterActivePlayMessage");
const CONFIG = require("../config.json");
const log = require("loglevel");
var interactivePlayMessage;
var updateInterval;
const init = (
message,
title,
artist,
imageURL,
itemURL,
getProgress,
onPrevious,
onPausePlay,
onStop,
onNext,
onRepeat,
playlistLenth
) => {
if (typeof interactivePlayMessage !== "undefined") {
destroy();
}
interactivePlayMessage = new InterActivePlayMessage(
message,
title,
artist,
imageURL,
itemURL,
getProgress,
onPrevious,
onPausePlay,
onStop,
onNext,
onRepeat,
playlistLenth
);
};
const destroy = () => {
if (typeof interactivePlayMessage !== "undefined") {
interactivePlayMessage.destroy();
interactivePlayMessage = undefined;
} else {
throw Error("No Interactive Message Found");
}
if (updateInterval !== "undefined") {
clearInterval(updateInterval);
updateInterval = undefined;
}
};
const hasMessage = () => {
if (typeof interactivePlayMessage === "undefined") {
return false;
} else {
return true;
}
};
/**
*
* @param {Function} callback function to retrieve current ticks
*/
const startUpate = (callback) => {
if (
typeof CONFIG["interactive-seek-bar-update-intervall"] === "number" &&
CONFIG["interactive-seek-bar-update-intervall"] > 0
) {
updateInterval = setInterval(() => {
interactivePlayMessage.updateProgress(callback());
}, CONFIG["interactive-seek-bar-update-intervall"]);
}
};
const updateCurrentSongMessage = (
title,
artist,
imageURL,
itemURL,
ticksLength,
playlistIndex,
playlistLenth
) => {
log.log(interactivePlayMessage);
if (typeof interactivePlayMessage !== "undefined") {
interactivePlayMessage.updateCurrentSongMessage(
title,
artist,
imageURL,
itemURL,
ticksLength,
playlistIndex,
playlistLenth
);
} else {
throw Error("No Interactive Message Found");
}
};
module.exports = {
init,
destroy,
hasMessage,
startUpate,
updateCurrentSongMessage
};

View File

@ -1,29 +0,0 @@
const { ApiClient, Events } = require("jellyfin-apiclient");
const CONFIG = require("../config.json");
const os = require("os");
var jellyfinClient;
function init() {
jellyfinClient = new ApiClient(
CONFIG["server-address"],
CONFIG["jellyfin-app-name"],
"0.0.1",
os.hostname(),
os.hostname()
);
}
function getJellyfinClient() {
return jellyfinClient;
}
function getJellyfinEvents() {
return Events;
}
module.exports = {
getJellyfinClient,
getJellyfinEvents,
init,
};

8
src/main.ts Normal file
View File

@ -0,0 +1,8 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();

View File

@ -1,308 +0,0 @@
const CONFIG = require("../config.json");
const Discord = require("discord.js");
const { checkJellyfinItemIDRegex } = require("./util");
const { hmsToSeconds, getDiscordEmbedError } = require("./util");
const discordclientmanager = require("./discordclientmanager");
const jellyfinClientManager = require("./jellyfinclientmanager");
const playbackmanager = require("./playbackmanager");
const websocketHanler = require("./websockethandler");
const discordClient = discordclientmanager.getDiscordClient();
var isSummendByPlay = false;
// random Color of the Jellyfin Logo Gradient
function getRandomDiscordColor() {
const random = Math.random();
function randomNumber(b, a) {
return (
Math.floor(random * Math.pow(Math.pow(b - a, 2), 1 / 2)) + (b > a ? a : b)
);
}
const GRANDIENT_START = "#AA5CC3";
const GRANDIENT_END = "#00A4DC";
let rS = GRANDIENT_START.slice(1, 3);
let gS = GRANDIENT_START.slice(3, 5);
let bS = GRANDIENT_START.slice(5, 7);
rS = parseInt(rS, 16);
gS = parseInt(gS, 16);
bS = parseInt(bS, 16);
let rE = GRANDIENT_END.slice(1, 3);
let gE = GRANDIENT_END.slice(3, 5);
let bE = GRANDIENT_END.slice(5, 7);
rE = parseInt(rE, 16);
gE = parseInt(gE, 16);
bE = parseInt(bE, 16);
return (
"#" +
("00" + randomNumber(rS, rE).toString(16)).substr(-2) +
("00" + randomNumber(gS, gE).toString(16)).substr(-2) +
("00" + randomNumber(bS, bE).toString(16)).substr(-2)
);
}
// Song Search, return the song itemID
async function searchForItemID(searchString) {
const response = await jellyfinClientManager
.getJellyfinClient()
.getSearchHints({
searchTerm: searchString,
includeItemTypes: "Audio,MusicAlbum,Playlist",
});
if (response.TotalRecordCount < 1) {
throw Error("Found nothing");
} else {
switch (response.SearchHints[0].Type) {
case "Audio":
return [response.SearchHints[0].ItemId];
case "Playlist":
case "MusicAlbum": {
const resp = await jellyfinClientManager
.getJellyfinClient()
.getItems(jellyfinClientManager.getJellyfinClient().getCurrentUserId(), {
sortBy: "SortName",
sortOrder: "Ascending",
parentId: response.SearchHints[0].ItemId,
});
const itemArray = [];
resp.Items.forEach((element) => {
itemArray.push(element.Id);
});
return itemArray;
}
}
}
}
function summon(voiceChannel) {
voiceChannel.join();
}
function summonMessage(message) {
if (!message.member.voice.channel) {
message.reply("please join a voice channel to summon me!");
} else if (message.channel.type === "dm") {
message.reply("no dms");
} else {
var desc = "**Joined Voice Channel** `";
desc = desc.concat(message.member.voice.channel.name).concat("`");
summon(message.member.voice.channel);
const vcJoin = new Discord.MessageEmbed()
.setColor(getRandomDiscordColor())
.setTitle("Joined Channel")
.setTimestamp()
.setDescription("<:loudspeaker:757929476993581117> " + desc);
message.channel.send(vcJoin);
}
}
async function playThis(message) {
const indexOfItemID =
message.content.indexOf(CONFIG["discord-prefix"] + "play") +
(CONFIG["discord-prefix"] + "play").length +
1;
const argument = message.content.slice(indexOfItemID);
let items;
// check if play command was used with itemID
const regexresults = checkJellyfinItemIDRegex(argument);
if (regexresults) {
items = regexresults;
} else {
try {
items = await searchForItemID(argument);
} catch (e) {
const noSong = getDiscordEmbedError(e);
message.channel.send(noSong);
playbackmanager.stop(
isSummendByPlay
? discordClient.user.client.voice.connections.first()
: undefined,
);
return;
}
}
playbackmanager.startPlaying(
discordClient.user.client.voice.connections.first(),
items,
0,
0,
isSummendByPlay,
);
playbackmanager.spawnPlayMessage(message);
}
async function addThis(message) {
const indexOfItemID =
message.content.indexOf(CONFIG["discord-prefix"] + "add") +
(CONFIG["discord-prefix"] + "add").length +
1;
const argument = message.content.slice(indexOfItemID);
let items;
// check if play command was used with itemID
const regexresults = checkJellyfinItemIDRegex(argument);
if (regexresults) {
items = regexresults;
} else {
try {
items = await searchForItemID(argument);
} catch (e) {
const noSong = getDiscordEmbedError(e);
message.channel.send(noSong);
return;
}
}
playbackmanager.addTracks(items);
}
function handleChannelMessage(message) {
getRandomDiscordColor();
if (message.content.startsWith(CONFIG["discord-prefix"] + "summon")) {
isSummendByPlay = false;
websocketHanler.openSocket();
summonMessage(message);
} else if (
message.content.startsWith(CONFIG["discord-prefix"] + "disconnect")
) {
playbackmanager.stop();
jellyfinClientManager.getJellyfinClient().closeWebSocket();
discordClient.user.client.voice.connections.forEach((element) => {
element.disconnect();
});
var desc = "**Left Voice Channel** `";
desc = desc.concat(message.member.voice.channel.name).concat("`");
const vcJoin = new Discord.MessageEmbed()
.setColor(getRandomDiscordColor())
.setTitle("Left Channel")
.setTimestamp()
.setDescription("<:wave:757938481585586226> " + desc);
message.channel.send(vcJoin);
} else if (
message.content.startsWith(CONFIG["discord-prefix"] + "pause") ||
message.content.startsWith(CONFIG["discord-prefix"] + "resume")
) {
try {
playbackmanager.playPause();
const noPlay = new Discord.MessageEmbed()
.setColor(0xff0000)
.setTitle("<:play_pause:757940598106882049> " + "Paused/Resumed.")
.setTimestamp();
message.channel.send(noPlay);
} catch (error) {
const errorMessage = getDiscordEmbedError(error);
message.channel.send(errorMessage);
}
} else if (message.content.startsWith(CONFIG["discord-prefix"] + "play")) {
if (discordClient.user.client.voice.connections.size < 1) {
summonMessage(message);
isSummendByPlay = true;
}
playThis(message);
} else if (message.content.startsWith(CONFIG["discord-prefix"] + "stop")) {
if (isSummendByPlay) {
if (discordClient.user.client.voice.connections.size > 0) {
playbackmanager.stop(discordClient.user.client.voice.connections.first());
}
} else {
playbackmanager.stop();
}
} else if (message.content.startsWith(CONFIG["discord-prefix"] + "seek")) {
const indexOfArgument =
message.content.indexOf(CONFIG["discord-prefix"] + "seek") +
(CONFIG["discord-prefix"] + "seek").length +
1;
const argument = message.content.slice(indexOfArgument);
try {
playbackmanager.seek(hmsToSeconds(argument) * 10000000);
} catch (error) {
const errorMessage = getDiscordEmbedError(error);
message.channel.send(errorMessage);
}
} else if (message.content.startsWith(CONFIG["discord-prefix"] + "skip")) {
try {
playbackmanager.nextTrack();
} catch (error) {
const errorMessage = getDiscordEmbedError(error);
message.channel.send(errorMessage);
}
} else if (message.content.startsWith(CONFIG["discord-prefix"] + "add")) {
addThis(message);
} else if (message.content.startsWith(CONFIG["discord-prefix"] + "spawn")) {
try {
playbackmanager.spawnPlayMessage(message);
} catch (error) {
const errorMessage = getDiscordEmbedError(error);
message.channel.send(errorMessage);
}
} else if (message.content.startsWith(CONFIG["discord-prefix"] + "help")) {
/* eslint-disable quotes */
const reply = new Discord.MessageEmbed()
.setColor(getRandomDiscordColor())
.setTitle(
"<:musical_note:757938541123862638> " +
"Jellyfin Discord Music Bot" +
" <:musical_note:757938541123862638> ",
)
.addFields(
{
name: `${CONFIG["discord-prefix"]}summon`,
value: "Join the channel the author of the message",
},
{
name: `${CONFIG["discord-prefix"]}disconnect`,
value: "Disconnect from all current Voice Channels",
},
{
name: `${CONFIG["discord-prefix"]}play`,
value: "Play the following item",
},
{
name: `${CONFIG["discord-prefix"]}add`,
value: "Add the following item to the current playlist",
},
{
name: `${CONFIG["discord-prefix"]}pause/resume`,
value: "Pause/Resume audio",
},
{
name: `${CONFIG["discord-prefix"]}seek`,
value: "Where to Seek to in seconds or MM:SS",
},
{
name: `${CONFIG["discord-prefix"]}skip`,
value: "Skip this Song",
},
{
name: `${CONFIG["discord-prefix"]}spawn`,
value: "Spawns an Interactive Play Controller",
},
{
name: `${CONFIG["discord-prefix"]}help`,
value: "Display this help message",
},
{
name: `GitHub`,
value:
"Find the code for this bot at: https://github.com/KGT1/jellyfin-discord-music-bot",
},
);
message.channel.send(reply);
/* eslint-enable quotes */
}
}
module.exports = {
handleChannelMessage,
};

View File

@ -1,535 +0,0 @@
const interactivemsghandler = require("./interactivemsghandler");
const CONFIG = require("../config.json");
const discordclientmanager = require("./discordclientmanager");
const log = require("loglevel");
const {
getAudioDispatcher,
setAudioDispatcher
} = require("./dispachermanager");
const { ticksToSeconds } = require("./util");
// this whole thing should be a class but its probably too late now.
var currentPlayingPlaylist;
var currentPlayingPlaylistIndex;
var isPaused;
var isRepeat;
var _disconnectOnFinish;
var _seek;
const jellyfinClientManager = require("./jellyfinclientmanager");
const { VoiceConnection } = require("discord.js");
function streamURLbuilder(itemID, bitrate) {
// so the server transcodes. Seems appropriate as it has the source file.(doesnt yet work i dont know why)
const supportedCodecs = "opus";
const supportedContainers = "ogg,opus";
return `${jellyfinClientManager
.getJellyfinClient()
.serverAddress()}/Audio/${itemID}/universal?UserId=${jellyfinClientManager
.getJellyfinClient()
.getCurrentUserId()}&DeviceId=${jellyfinClientManager
.getJellyfinClient()
.deviceId()}&MaxStreamingBitrate=${bitrate}&Container=${supportedContainers}&AudioCodec=${supportedCodecs}&api_key=${jellyfinClientManager
.getJellyfinClient()
.accessToken()}&TranscodingContainer=ts&TranscodingProtocol=hls`;
}
function startPlaying(
voiceconnection = discordclientmanager
.getDiscordClient()
.user.client.voice.connections.first(),
itemIDPlaylist = currentPlayingPlaylist,
playlistIndex = currentPlayingPlaylistIndex,
seekTo,
disconnectOnFinish = _disconnectOnFinish
) {
log.debug(
"Start playing",
itemIDPlaylist[playlistIndex],
"with index",
playlistIndex,
"of list with length of",
itemIDPlaylist.length,
"in",
voiceconnection && voiceconnection.channel
? '"' +
voiceconnection.channel.name +
'" (' +
voiceconnection.channel.id +
")"
: "an unknown voice channel"
);
isPaused = false;
currentPlayingPlaylist = itemIDPlaylist;
currentPlayingPlaylistIndex = playlistIndex;
_disconnectOnFinish = disconnectOnFinish;
_seek = seekTo * 1000;
updatePlayMessage();
async function playasync() {
const url = streamURLbuilder(
itemIDPlaylist[playlistIndex],
voiceconnection.channel.bitrate
);
setAudioDispatcher(
voiceconnection.play(url, {
seek: seekTo
})
);
if (seekTo) {
jellyfinClientManager
.getJellyfinClient()
.reportPlaybackProgress(getProgressPayload());
} else {
jellyfinClientManager.getJellyfinClient().reportPlaybackStart({
userID: `${jellyfinClientManager.getJellyfinClient().getCurrentUserId()}`,
itemID: `${itemIDPlaylist[playlistIndex]}`,
canSeek: true,
playSessionId: getPlaySessionId(),
playMethod: getPlayMethod()
});
}
getAudioDispatcher().on("finish", () => {
// report playback stop and start the same index again
if (isRepeat) {
reportPlaybackStoppedAndStartPlaying(
voiceconnection,
currentPlayingPlaylistIndex
);
return;
}
if (currentPlayingPlaylist.length < playlistIndex) {
if (disconnectOnFinish) {
stop(voiceconnection, currentPlayingPlaylist[playlistIndex - 1]);
return;
}
stop(undefined, currentPlayingPlaylist[playlistIndex - 1]);
return;
}
// play the next song in the playlist
reportPlaybackStoppedAndStartPlaying(
voiceconnection,
currentPlayingPlaylistIndex + 1
);
});
}
playasync().catch((rsn) => {
console.error(rsn);
});
}
/**
*
* @param {VoiceConnection} voiceconnection - The voiceConnection where the bot should play
* @param {number} playlistIndex - The target playlist index
* @param {any} disconnectOnFinish
*/
const reportPlaybackStoppedAndStartPlaying = (
voiceconnection,
playlistIndex,
disconnectOnFinish
) => {
const stopPayload = getStopPayload();
log.debug(
"Repeat and sending following payload as reportPlaybackStopped to the server: ",
stopPayload
);
jellyfinClientManager.getJellyfinClient().reportPlaybackStopped(stopPayload);
startPlaying(voiceconnection, undefined, playlistIndex, 0, disconnectOnFinish);
};
async function spawnPlayMessage(message) {
if (!message.channel) {
log.error("Unable to send play message in channel");
log.debug(message);
return;
}
log.debug(
"Sending play message to channel",
message.channel.name,
"(" + message.channel.id + ")"
);
const itemIdDetails = await jellyfinClientManager
.getJellyfinClient()
.getItem(
jellyfinClientManager.getJellyfinClient().getCurrentUserId(),
getItemId()
);
const imageURL = await jellyfinClientManager
.getJellyfinClient()
.getImageUrl(itemIdDetails.AlbumId || getItemId(), { type: "Primary" });
try {
interactivemsghandler.init(
message,
itemIdDetails.Name,
itemIdDetails.Artists[0] || "VA",
imageURL,
`${jellyfinClientManager
.getJellyfinClient()
.serverAddress()}/web/index.html#!/details?id=${itemIdDetails.AlbumId}`,
itemIdDetails.RunTimeTicks,
ticksToSeconds(getPostitionTicks()) > 10 ? previousTrack : seek,
playPause,
() => {
stop(
_disconnectOnFinish
? discordclientmanager
.getDiscordClient()
.user.client.voice.connections.first()
: undefined
);
},
nextTrack,
() => {
setIsRepeat(!isRepeat);
},
currentPlayingPlaylist.length
);
if (typeof CONFIG["interactive-seek-bar-update-intervall"] === "number") {
interactivemsghandler.startUpate(getPostitionTicks);
}
} catch (error) {
log.error(error);
}
}
async function updatePlayMessage() {
const itemId = getItemId();
if (!itemId) {
return;
}
const jellyfinItemDetails = await jellyfinClientManager
.getJellyfinClient()
.getItem(
jellyfinClientManager.getJellyfinClient().getCurrentUserId(),
getItemId()
);
const primaryAlbumCover = await jellyfinClientManager
.getJellyfinClient()
.getImageUrl(jellyfinItemDetails.AlbumId || itemId, { type: "Primary" });
log.debug("Extracted primary Album cover url:", primaryAlbumCover);
try {
interactivemsghandler.updateCurrentSongMessage(
jellyfinItemDetails.Name,
jellyfinItemDetails.Artists[0] || "VA",
primaryAlbumCover,
`${jellyfinClientManager
.getJellyfinClient()
.serverAddress()}/web/index.html#!/details?id=${
jellyfinItemDetails.AlbumId
}`,
jellyfinItemDetails.RunTimeTicks,
currentPlayingPlaylistIndex + 1,
currentPlayingPlaylist.length
);
} catch (exception) {
log.error("Exception during updating the current song message:", exception);
}
}
/**
* @param {Number} toSeek - where to seek in ticks
*/
function seek(toSeek = 0) {
log.debug("Seeking to: ", toSeek);
if (!getAudioDispatcher()) {
log.warn("Failed to seek because no song is playing.");
}
// start playing the same track but with a specified time
startPlaying(
undefined,
undefined,
undefined,
ticksToSeconds(toSeek),
_disconnectOnFinish
);
// report change about playback progress to Jellyfin
jellyfinClientManager
.getJellyfinClient()
.reportPlaybackProgress(getProgressPayload());
}
/**
*
* @param {Array} trackItemIdsArray - array of itemIDs to be added
*/
function addTracks(trackItemIdsArray) {
currentPlayingPlaylist = currentPlayingPlaylist.concat(trackItemIdsArray);
log.debug(
"Added tracks of",
trackItemIdsArray.length,
"to the current playlist"
);
}
function nextTrack() {
log.debug("Going to the next track...");
if (!currentPlayingPlaylist) {
log.warn(
"Can't go to the next track, because there is currently nothing playing"
);
return;
}
if (currentPlayingPlaylistIndex + 1 >= currentPlayingPlaylist.length) {
log.warn(
"Can't go to next track, because the current playing song is the last song."
);
return;
}
reportPlaybackStoppedAndStartPlaying(
undefined,
currentPlayingPlaylistIndex + 1,
_disconnectOnFinish
);
}
function previousTrack() {
log.debug("Going to the previous track...");
if (ticksToSeconds(getPostitionTicks()) > 10) {
return;
}
// don't go to the previous track when nothing is playing
if (!currentPlayingPlaylist) {
log.warn(
"Can't go to the previous track, because there's currently nothing playing"
);
return;
}
if (currentPlayingPlaylistIndex - 1 < 0) {
log.warn(
"Can't go to the previous track, because this is the first track in the playlist"
);
return;
}
reportPlaybackStoppedAndStartPlaying(
undefined,
currentPlayingPlaylistIndex - 1,
_disconnectOnFinish
);
}
/**
* @param {Object=} disconnectVoiceConnection - Optional The voice Connection do disconnect from
*/
function stop(disconnectVoiceConnection, itemId = getItemId()) {
isPaused = true;
if (interactivemsghandler.hasMessage()) {
interactivemsghandler.destroy();
}
if (disconnectVoiceConnection) {
disconnectVoiceConnection.disconnect();
}
log.debug(
"stop playback and send following payload as reportPlaybackStopped to the server: ",
getStopPayload()
);
jellyfinClientManager
.getJellyfinClient()
.reportPlaybackStopped(getStopPayload());
if (getAudioDispatcher()) {
try {
getAudioDispatcher().destroy();
} catch (error) {
console.error(error);
}
}
setAudioDispatcher(undefined);
}
function pause() {
log.debug("Pausing the current track...");
isPaused = true;
// report to Jellyfin that the client has paused the track
jellyfinClientManager
.getJellyfinClient()
.reportPlaybackProgress(getProgressPayload());
// pause the track in the audio dispatcher
getAudioDispatcher().pause(true);
}
function resume() {
log.debug("Resuming playback of the current track...");
isPaused = false;
// report to Jellyfin that the client has resumed playback
jellyfinClientManager
.getJellyfinClient()
.reportPlaybackProgress(getProgressPayload());
// resume playback in the audio dispatcher
getAudioDispatcher().resume();
}
/**
* Pauses the playback of the current track is playing or
* resumes the placback if the current track is paused
*/
function playPause() {
const audioDispatcher = getAudioDispatcher();
if (!audioDispatcher) {
log.warn(
"Can't toggle the playback of the current song because there is nothing playing right now"
);
return;
}
if (audioDispatcher.paused) {
log.debug("Resuming playback because the current track is paused...");
resume();
return;
}
log.debug("Pausing the playback because the current track is playing...");
pause();
}
function getPostitionTicks() {
// this is very sketchy but i dont know how else to do it
return (
(_seek + getAudioDispatcher().streamTime - getAudioDispatcher().pausedTime) *
10000
);
}
function getPlayMethod() {
// TODO figure out how to figure this out
return "DirectPlay";
}
function getRepeatMode() {
if (isRepeat) {
return "RepeatOne";
}
return "RepeatNone";
}
function getPlaylistItemId() {
return getItemId();
}
function getPlaySessionId() {
// TODO: generate a unique identifier for identification at Jellyfin. This may cause conflicts when running multiple bots on the same Jellyfin server.
return "ae2436edc6b91b11d72aeaa67f84e0ea";
}
function getNowPLayingQueue() {
return [
{
Id: getItemId(),
// as I curently dont support Playlists
PlaylistItemId: getPlaylistItemId()
}
];
}
function getCanSeek() {
return true;
}
function getIsMuted() {
return false;
}
function getVolumeLevel() {
return 100;
}
function getItemId() {
if (typeof currentPlayingPlaylist !== "undefined") {
return currentPlayingPlaylist[currentPlayingPlaylistIndex];
}
return undefined;
}
function getIsPaused() {
// AudioDispacker Paused is to slow
if (isPaused === undefined) {
isPaused = false;
}
return isPaused;
}
function setIsRepeat(arg) {
if (arg === undefined) {
if (!(isRepeat === undefined)) {
isRepeat = !isRepeat;
}
}
isRepeat = arg;
}
function getProgressPayload() {
const payload = {
CanSeek: getCanSeek(),
IsMuted: getIsMuted(),
IsPaused: getIsPaused(),
ItemId: getItemId(),
MediaSourceId: getItemId(),
NowPlayingQueue: getNowPLayingQueue(),
PlayMethod: getPlayMethod(),
PlaySessionId: getPlaySessionId(),
PlaylistItemId: getPlaylistItemId(),
PositionTicks: getPostitionTicks(),
RepeatMode: getRepeatMode(),
VolumeLevel: getVolumeLevel(),
EventName: "pauseplayupdate"
};
return payload;
}
function getStopPayload() {
return {
userId: jellyfinClientManager.getJellyfinClient().getCurrentUserId(),
itemId: getItemId(),
sessionID: getPlaySessionId(),
playSessionId: getPlaySessionId(),
positionTicks: getPostitionTicks()
};
}
module.exports = {
startPlaying,
stop,
playPause,
resume,
pause,
seek,
setIsRepeat,
nextTrack,
previousTrack,
addTracks,
getPostitionTicks,
spawnPlayMessage
};

3
src/types/env.ts Normal file
View File

@ -0,0 +1,3 @@
export interface EnvironmentVariablesType {
DISCORD_CLIENT_TOKEN: string;
}

View File

@ -1,55 +0,0 @@
function checkJellyfinItemIDRegex(strgintomatch) {
const regexresult = strgintomatch.match(/([0-9]|[a-f]){32}/);
if (regexresult) {
return [regexresult[0]];
} else {
return undefined;
}
}
function ticksToSeconds(ticks) {
return ticks / 10000000;
}
function hmsToSeconds(str) {
var p = str.split(":");
var s = 0;
var m = 1;
while (p.length > 0) {
s += m * parseInt(p.pop(), 10);
m *= 60;
}
return s;
}
function secondsToHms(totalSeconds) {
const hours = Math.floor(totalSeconds / 3600);
totalSeconds %= 3600;
const minutes = Math.floor(totalSeconds / 60);
let seconds = Math.floor(totalSeconds % 60);
seconds = seconds < 10 && seconds > 0 ? `0${seconds}` : `${seconds}`;
if (hours > 0) {
return `${hours}:${minutes}:${seconds}`;
} else {
return `${minutes}:${seconds}`;
}
}
function getDiscordEmbedError(e) {
const Discord = require("discord.js");
return new Discord.MessageEmbed()
.setColor(0xff0000)
.setTitle("Error!")
.setTimestamp()
.setDescription("<:x:757935515445231651> " + e);
}
module.exports = {
checkJellyfinItemIDRegex,
ticksToSeconds,
hmsToSeconds,
getDiscordEmbedError,
secondsToHms,
};

6
src/utils/constants.ts Normal file
View File

@ -0,0 +1,6 @@
export const Constants = {
Metadata: {
Version: "0.0.1",
ApplicationName: "Discord Jellyfin Music Bot"
}
}

View File

@ -1,56 +0,0 @@
const jellyfinClientManager = require("./jellyfinclientmanager");
const playbackmanager = require("./playbackmanager");
const { ticksToSeconds } = require("./util");
function openSocket() {
jellyfinClientManager.getJellyfinClient().openWebSocket();
jellyfinClientManager.getJellyfinClient().reportCapabilities({
PlayableMediaTypes: "Audio",
SupportsMediaControl: true,
SupportedCommands: "SetRepeatMode,Play,Playstate",
});
jellyfinClientManager
.getJellyfinEvents()
.on(jellyfinClientManager.getJellyfinClient(), "message", (type, data) => {
if (data.MessageType === "Play") {
if (data.Data.PlayCommand === "PlayNow") {
playbackmanager.startPlaying(
undefined,
data.Data.ItemIds,
data.Data.StartIndex || 0,
0,
false
);
}
} else if (data.MessageType === "Playstate") {
if (data.Data.Command === "PlayPause") {
playbackmanager.playPause();
} else if (data.Data.Command === "Stop") {
playbackmanager.stop();
} else if (data.Data.Command === "Seek") {
// because the server sends seek an privious track at same time so i have to do timing
setTimeout(async () => {
playbackmanager.seek(data.Data.SeekPositionTicks);
}, 20);
} else if (data.Data.Command === "NextTrack") {
try {
playbackmanager.nextTrack();
} catch (error) {
console.error(error);
}
} else if (data.Data.Command === "PreviousTrack") {
try {
if (ticksToSeconds(playbackmanager.getPostitionTicks()) < 10) {
playbackmanager.previousTrack();
}
} catch (error) {
console.error(error);
}
}
}
});
}
module.exports = {
openSocket,
};

24
test/app.e2e-spec.ts Normal file
View File

@ -0,0 +1,24 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

9
test/jest-e2e.json Normal file
View File

@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

4
tsconfig.build.json Normal file
View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "es2017",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}

9883
yarn.lock

File diff suppressed because it is too large Load Diff