Migration to NestJS framework #6

This commit is contained in:
Manuel 2022-12-18 20:07:52 +01:00 committed by GitHub
commit acd8018207
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
70 changed files with 13732 additions and 4125 deletions

View File

@ -1,3 +1,6 @@
img docs
test
images
node_modules node_modules
package-lock.json package-lock.json
yarn.lock

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

View File

@ -1,5 +1,5 @@
--- ---
name: Bug report name: 🐛 Bug report
about: Create a report to help us improve about: Create a report to help us improve
title: '' title: ''
labels: '' labels: ''

View File

@ -1,5 +1,5 @@
--- ---
name: Feature request name: Feature request
about: Suggest an idea for this project about: Suggest an idea for this project
title: '' title: ''
labels: '' labels: ''

44
.gitignore vendored
View File

@ -1,4 +1,40 @@
node_modules # compiled output
package-lock.json /dist
config.json /node_modules
.prettierrc .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
.vscode
# 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

185
README.md
View File

@ -1,145 +1,54 @@
<p align="center"> <p align="center">
<img src="https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/jellyfin.png" alt="Jellyfin Logo" width="80" height="80"> <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>
<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>
</p> </p>
# ✨ Features <br/>
- Simple Discord Bot that hooks into the [Jellyfin](http://github.com/jellyfin/jellyfin) API of your instance <h1 align="center">Jellyfin Discord Bot</h1>
- Request, pause and play songs directly from your Discord Server
- Interactive Media control message to control playback
# 🦾 About this fork <p align="center">A simple <a href="https://discord.com" target="_blank">Discord</a> bot that enables you to broadcast<br/>your <a href="https://jellyfin.org/" target="_blank">Jellyfin Media Server</a> music collection to voice channels.<br/>It's Open Source and can easily be hosted by yourself!</p>
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).
I will gradually update documentation & code of the bot. Please wait patiently. <p align="center">
<small>Thanky you <a href="https://github.com/KGT1/jellyfin-discord-music-bot/">KGT1</a> for starting this project!<br/>This is a fork of their original repository and re-uses some of their code.</small>
<br/><br/><br/><br/><br/><br/><hr/><br/> </p>
## Original README from https://github.com/KGT1/jellyfin-discord-music-bot
Jellyfin Discord Music Bot is a Discord Bot for the [Jellyfin Media Server!](http://github.com/jellyfin/jellyfin)
### Capabilities
#### Play to
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:
```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
```
MESSAGE_UPDATE_INTERVAL is the amount of time in ms the play message gets updated with the current time
#### Alternatively you can run the Application natively with NodeJS:
Dependencies:
- 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
```
edit config.json and add your token,server-address etc.
```bash
npm run start
```
### How to build <br/>
``` <hr/>
git clone https://github.com/kgt1/jellyfin-discord-music-bot.git <br/>
cd jellyfin-discord-music-bot
docker build -t YOUR_IMAGE_NAME .
``` ## ✨ Features
- Leighweight and extendable using the [Nest](https://github.com/nestjs/nest) framework
- Easy usage with Discord command system (eg. ``/play``, ``/pause``, ...)
- Fast and validated configuration using environment variables
- Typesafe code for quicker development and less bugs
- Supports ``Music``, ``Playlists`` and ``Albums`` from your Jellyfin instance
## 📌 About this project
This project was originally started by [KGT1 on Github](https://github.com/KGT1/jellyfin-discord-music-bot/) in 2020. I came accross this project in late 2021, when wanted to enjoy my music on Discord. I never got it to run as I wanted it to. Since the original project was created under the MIT license, I decided to make a fork in 2022 with my own version. Although this project re-uses some code of the original project, it has been completly rewritten in other parts using NestJs and features now a module-based approach.
## ⛔ Limitations
- Bot does not support shards. This means, you cannot use it in multiple servers concurrently.
- Displaying media covers or images in Discord (Jellyfin is self hosted, and other users woudln't be able to see those images)
- Streaming any video content in voice channels (See [this issue](https://github.com/discordjs/discord.js/issues/4116))
## 🚀 Installation
Please check out the Wiki section in the repository for installation instructions:
https://github.com/manuel-rw/jellyfin-discord-music-bot/wiki
> Docker container comming soon
## 💻 Development
I'm open to any contributions to this project. You can start contributing using the following commands, after executing the installation commands:
## 👤 Credits
- https://tabler-icons.io/ (MIT)
- https://docs.nestjs.com/ (MIT)
- https://discord.js.org/ (Apache 2.0)

View File

@ -1,10 +0,0 @@
{
"token": "",
"server-address": "",
"jellyfin-username": "",
"jellyfin-password": "",
"discord-prefix": "?",
"jellyfin-app-name": "Jellyfin Discord Music Bot",
"interactive-seek-bar-update-intervall": 10000,
"log-level": "info"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

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,83 @@
{ {
"name": "jellyfin-discord-music-bot", "name": "jellyfin-discord-music-bot",
"version": "0.0.1", "version": "0.0.1",
"description": "Jellyfin Discord Music Bot is a Discord Bot for the Jellyfin Media Server!", "description": "",
"main": "src/index.js", "author": "manuel-rw",
"scripts": { "private": true,
"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",
"license": "MIT", "license": "MIT",
"bugs": { "scripts": {
"url": "https://github.com/KGT1/jellyfin-discord-music-bot/issues" "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": { "dependencies": {
"@discordjs/opus": "^0.3.2", "@discord-nestjs/common": "^4.0.8",
"chalk": "^4.1.0", "@discord-nestjs/core": "^4.3.1",
"discord.js": "^12.3.1", "@discordjs/opus": "^0.9.0",
"jellyfin-apiclient": "1.7.0", "@discordjs/voice": "^0.14.0",
"loglevel": "^1.7.1", "@jellyfin/sdk": "^0.7.0",
"loglevel-plugin-prefix": "^0.8.4", "@nestjs/common": "^9.0.0",
"node-fetch": "^2.6.0", "@nestjs/config": "^2.2.0",
"nodejs": "0.0.0", "@nestjs/core": "^9.0.0",
"window": "^4.2.7", "@nestjs/event-emitter": "^1.3.1",
"ws": "^7.3.1" "@nestjs/platform-express": "^9.0.0",
"date-fns": "^2.29.3",
"discord.js": "^14.7.1",
"joi": "^17.7.0",
"libsodium-wrappers": "^0.7.10",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0",
"uuid": "^9.0.0"
}, },
"devDependencies": { "devDependencies": {
"patch-package": "^6.4.7", "@nestjs/cli": "^9.0.0",
"eslint": "^7.9.0", "@nestjs/schematics": "^9.0.0",
"eslint-config-standard": "^14.1.1", "@nestjs/testing": "^9.0.0",
"eslint-plugin-import": "^2.22.0", "@types/express": "^4.17.13",
"eslint-plugin-node": "^11.1.0", "@types/jest": "28.1.8",
"eslint-plugin-promise": "^4.2.1", "@types/node": "^16.0.0",
"eslint-plugin-standard": "^4.0.1" "@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"
} }
} }

View File

@ -1,16 +0,0 @@
const fs = require("fs");
const filename = "./config.json";
const configfile = require(filename);
if (process.env.DISCORD_PREFIX) { configfile["discord-prefix"] = process.env.DISCORD_PREFIX; }
if (process.env.DISCORD_TOKEN) { configfile.token = process.env.DISCORD_TOKEN; }
if (process.env.JELLYFIN_SERVER_ADDRESS) { configfile["server-address"] = process.env.JELLYFIN_SERVER_ADDRESS; }
if (process.env.JELLYFIN_USERNAME) { configfile["jellyfin-username"] = process.env.JELLYFIN_USERNAME; }
if (process.env.JELLYFIN_PASSWORD) { configfile["jellyfin-password"] = process.env.JELLYFIN_PASSWORD; }
if (process.env.JELLYFIN_APP_NAME) { configfile["jellyfin-app-name"] = process.env.JELLYFIN_APP_NAME; }
if (process.env.MESSAGE_UPDATE_INTERVAL) { configfile["interactive-seek-bar-update-intervall"] = parseInt(process.env.MESSAGE_UPDATE_INTERVAL); }
if (process.env.LOG_LEVEL) { configfile["log-level"] = process.env.LOG_LEVEL; }
fs.writeFile(filename, JSON.stringify(configfile, null, 1), (err) => {
if (err) return console.error(err);
});

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;

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

@ -0,0 +1,37 @@
import { Module } from '@nestjs/common';
import * as Joi from 'joi';
import { DiscordModule } from '@discord-nestjs/core';
import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { DiscordConfigService } from './clients/discord/discord.config.service';
import { DiscordClientModule } from './clients/discord/discord.module';
import { JellyfinClientModule } from './clients/jellyfin/jellyfin.module';
import { CommandModule } from './commands/command.module';
import { PlaybackModule } from './playback/playback.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(),
}),
}),
DiscordModule.forRootAsync({
useClass: DiscordConfigService,
}),
DiscordModule,
EventEmitterModule.forRoot(),
CommandModule,
DiscordClientModule,
JellyfinClientModule,
PlaybackModule,
],
controllers: [],
providers: [],
})
export class AppModule {}

View File

@ -0,0 +1,24 @@
import {
DiscordModuleOption,
DiscordOptionsFactory,
} from '@discord-nestjs/core';
import { Injectable } from '@nestjs/common';
import { GatewayIntentBits } from 'discord.js';
@Injectable()
export class DiscordConfigService implements DiscordOptionsFactory {
createDiscordOptions(): DiscordModuleOption {
return {
token: process.env.DISCORD_CLIENT_TOKEN,
discordClientOptions: {
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildIntegrations,
GatewayIntentBits.GuildVoiceStates,
],
},
};
}
}

View File

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

View File

@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { OnModuleDestroy } from '@nestjs/common/interfaces/hooks';
import { PlaybackModule } from '../../playback/playback.module';
import { DiscordConfigService } from './discord.config.service';
import { DiscordMessageService } from './discord.message.service';
import { DiscordVoiceService } from './discord.voice.service';
@Module({
imports: [PlaybackModule],
controllers: [],
providers: [DiscordConfigService, DiscordVoiceService, DiscordMessageService],
exports: [DiscordConfigService, DiscordVoiceService, DiscordMessageService],
})
export class DiscordClientModule implements OnModuleDestroy {
constructor(private readonly discordVoiceService: DiscordVoiceService) {}
onModuleDestroy() {
this.discordVoiceService.disconnectGracefully();
}
}

View File

@ -0,0 +1,223 @@
import {
AudioPlayer,
AudioPlayerStatus,
AudioResource,
createAudioPlayer,
createAudioResource,
getVoiceConnection,
getVoiceConnections,
joinVoiceChannel,
VoiceConnection,
} from '@discordjs/voice';
import { Injectable } from '@nestjs/common';
import { Logger } from '@nestjs/common/services';
import { OnEvent } from '@nestjs/event-emitter';
import { GuildMember } from 'discord.js';
import { GenericTryHandler } from '../../models/generic-try-handler';
import { PlaybackService } from '../../playback/playback.service';
import { Track } from '../../types/track';
import { DiscordMessageService } from './discord.message.service';
@Injectable()
export class DiscordVoiceService {
private readonly logger = new Logger(DiscordVoiceService.name);
private audioPlayer: AudioPlayer;
private voiceConnection: VoiceConnection;
constructor(
private readonly discordMessageService: DiscordMessageService,
private readonly playbackService: PlaybackService,
) {}
@OnEvent('playback.newTrack')
handleOnNewTrack(newTrack: Track) {
const resource = createAudioResource(newTrack.streamUrl);
this.playResource(resource);
}
tryJoinChannelAndEstablishVoiceConnection(
member: GuildMember,
): GenericTryHandler {
if (this.voiceConnection !== undefined) {
return {
success: true,
reply: {},
};
}
if (member.voice.channel === null) {
this.logger.log(
`Unable to join a voice channel because the member ${member.user.username} is not in a voice channel`,
);
return {
success: false,
reply: {
embeds: [
this.discordMessageService.buildErrorMessage({
title: 'Unable to join your channel',
description:
"I am unable to join your channel, because you don't seem to be in a voice channel. Connect to a channel first to use this command",
}),
],
},
};
}
const channel = member.voice.channel;
joinVoiceChannel({
channelId: channel.id,
adapterCreator: channel.guild.voiceAdapterCreator,
guildId: channel.guildId,
});
if (this.voiceConnection == undefined) {
this.voiceConnection = getVoiceConnection(member.guild.id);
}
return {
success: true,
reply: {},
};
}
playResource(resource: AudioResource<unknown>) {
this.createAndReturnOrGetAudioPlayer().play(resource);
}
/**
* Pauses the current audio player
*/
pause() {
this.createAndReturnOrGetAudioPlayer().pause();
}
/**
* Stops the audio player
*/
stop(force: boolean): boolean {
return this.createAndReturnOrGetAudioPlayer().stop(force);
}
/**
* Unpauses the current audio player
*/
unpause() {
this.createAndReturnOrGetAudioPlayer().unpause();
}
/**
* Check if the current state is paused
* @returns The current pause state as a boolean
*/
isPaused() {
return (
this.createAndReturnOrGetAudioPlayer().state.status ===
AudioPlayerStatus.Paused
);
}
/**
* Gets the current audio player status
* @returns The current audio player status
*/
getPlayerStatus(): AudioPlayerStatus {
return this.createAndReturnOrGetAudioPlayer().state.status;
}
/**
* 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
*/
togglePaused(): boolean {
if (this.isPaused()) {
this.unpause();
return false;
}
this.pause();
return true;
}
disconnect(): GenericTryHandler {
if (this.voiceConnection === undefined) {
return {
success: false,
reply: {
embeds: [
this.discordMessageService.buildErrorMessage({
title: 'Unable to disconnect from voice channel',
description: 'I am currently not connected to any voice channels',
}),
],
},
};
}
this.voiceConnection.destroy();
return {
success: true,
reply: {},
};
}
disconnectGracefully() {
const connections = getVoiceConnections();
this.logger.debug(
`Disonnecting gracefully from ${
Object.keys(connections).length
} connections`,
);
connections.forEach((connection) => {
connection.destroy();
});
}
private createAndReturnOrGetAudioPlayer() {
if (this.audioPlayer === undefined) {
this.logger.debug(
`Initialized new instance of Audio Player because it has not been defined yet`,
);
this.audioPlayer = createAudioPlayer();
this.attachEventListenersToAudioPlayer();
this.voiceConnection.subscribe(this.audioPlayer);
return this.audioPlayer;
}
return this.audioPlayer;
}
private attachEventListenersToAudioPlayer() {
this.audioPlayer.on('debug', (message) => {
this.logger.debug(message);
});
this.audioPlayer.on('error', (message) => {
this.logger.error(message);
});
this.audioPlayer.on('stateChange', (previousState) => {
if (previousState.status !== AudioPlayerStatus.Playing) {
return;
}
if (this.audioPlayer.state.status !== AudioPlayerStatus.Idle) {
return;
}
const hasNextTrack = this.playbackService.hasNextTrack();
this.logger.debug(
`Deteced audio player status change from ${previousState.status} to ${
this.audioPlayer.state.status
}. Has next track: ${hasNextTrack ? 'yes' : 'no'}`,
);
if (!hasNextTrack) {
this.logger.debug(`Audio Player has reached the end of the playlist`);
return;
}
this.playbackService.nextTrack();
});
}
}

View File

@ -0,0 +1,33 @@
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';
@Module({
imports: [],
controllers: [],
providers: [
JellyfinService,
JellyinWebsocketService,
JellyfinSearchService,
JellyfinStreamBuilderService,
],
exports: [
JellyfinService,
JellyfinSearchService,
JellyfinStreamBuilderService,
],
})
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,105 @@
import { Injectable } from '@nestjs/common';
import { JellyfinService } from './jellyfin.service';
import {
BaseItemKind,
SearchHint,
} from '@jellyfin/sdk/lib/generated-client/models';
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api';
import { getPlaylistsApi } from '@jellyfin/sdk/lib/utils/api/playlists-api';
import { getSearchApi } from '@jellyfin/sdk/lib/utils/api/search-api';
import { Logger } from '@nestjs/common/services';
import {
JellyfinAudioPlaylist,
JellyfinMusicAlbum,
} from '../../models/jellyfinAudioItems';
@Injectable()
export class JellyfinSearchService {
private readonly logger = new Logger(JellyfinSearchService.name);
constructor(private readonly jellyfinService: JellyfinService) {}
async search(searchTerm: string): Promise<SearchHint[]> {
const api = this.jellyfinService.getApi();
this.logger.debug(`Searching for '${searchTerm}'`);
const searchApi = getSearchApi(api);
const {
data: { SearchHints, TotalRecordCount },
status,
} = await searchApi.get({
searchTerm: searchTerm,
includeItemTypes: [
BaseItemKind.Audio,
BaseItemKind.MusicAlbum,
BaseItemKind.Playlist,
],
});
if (status !== 200) {
this.logger.error(`Jellyfin Search failed with status code ${status}`);
return [];
}
this.logger.debug(`Found ${TotalRecordCount} results for '${searchTerm}'`);
return SearchHints;
}
async getPlaylistById(id: string): Promise<JellyfinAudioPlaylist> {
const api = this.jellyfinService.getApi();
const searchApi = getPlaylistsApi(api);
const axiosResponse = await searchApi.getPlaylistItems({
userId: this.jellyfinService.getUserId(),
playlistId: id,
});
if (axiosResponse.status !== 200) {
this.logger.error(
`Jellyfin Search failed with status code ${axiosResponse.status}`,
);
return new JellyfinAudioPlaylist();
}
return axiosResponse.data as JellyfinAudioPlaylist;
}
async getItemsByAlbum(albumId: string): Promise<JellyfinMusicAlbum> {
const api = this.jellyfinService.getApi();
const searchApi = getSearchApi(api);
const axiosResponse = await searchApi.get({
parentId: albumId,
userId: this.jellyfinService.getUserId(),
mediaTypes: [BaseItemKind[BaseItemKind.Audio]],
searchTerm: '%',
});
if (axiosResponse.status !== 200) {
this.logger.error(
`Jellyfin Search failed with status code ${axiosResponse.status}`,
);
return new JellyfinMusicAlbum();
}
return axiosResponse.data as JellyfinMusicAlbum;
}
async getById(id: string): Promise<SearchHint> {
const api = this.jellyfinService.getApi();
const searchApi = getItemsApi(api);
const { data } = await searchApi.getItems({
ids: [id],
});
if (data.Items.length !== 1) {
this.logger.warn(`Failed to retrieve item via id '${id}'`);
return null;
}
return data.Items[0];
}
}

View File

@ -0,0 +1,88 @@
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';
@Injectable()
export class JellyfinService {
private readonly logger = new Logger(JellyfinService.name);
private jellyfin: Jellyfin;
private api: Api;
private systemApi: SystemApi;
private userId: string;
constructor(private readonly eventEmitter: EventEmitter2) {}
init() {
this.jellyfin = new Jellyfin({
clientInfo: {
name: Constants.Metadata.ApplicationName,
version: Constants.Metadata.Version,
},
deviceInfo: {
id: 'jellyfin-discord-bot',
name: 'Jellyfin Discord Bot',
},
});
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) => {
if (response.data.SessionInfo === undefined) {
this.logger.error(
`Failed to authenticate with response code ${response.status}: '${response.data}'`,
);
return;
}
this.logger.debug(
`Connected using user '${response.data.SessionInfo.UserId}'`,
);
this.userId = response.data.SessionInfo.UserId;
this.systemApi = getSystemApi(this.api);
this.eventEmitter.emit('clients.jellyfin.ready');
})
.catch((test) => {
this.logger.error(test);
});
}
destroy() {
if (!this.api) {
this.logger.warn(
'Jellyfin Api Client was unexpectitly undefined. Graceful destroy has failed',
);
return;
}
this.api.logout();
}
getApi() {
return this.api;
}
getJellyfin() {
return this.jellyfin;
}
getSystemApi() {
return this.systemApi;
}
getUserId() {
return this.userId;
}
}

View File

@ -0,0 +1,35 @@
import { Injectable, Logger } from '@nestjs/common';
import { JellyfinService } from './jellyfin.service';
@Injectable()
export class JellyfinStreamBuilderService {
private readonly logger = new Logger(JellyfinStreamBuilderService.name);
constructor(private readonly jellyfinService: JellyfinService) {}
buildStreamUrl(jellyfinItemId: string, bitrate: number) {
const api = this.jellyfinService.getApi();
this.logger.debug(
`Attempting to build stream resource for item ${jellyfinItemId} with bitrate ${bitrate}`,
);
const accessToken = this.jellyfinService.getApi().accessToken;
const uri = new URL(api.basePath);
uri.pathname = `/Audio/${jellyfinItemId}/universal`;
uri.searchParams.set('UserId', this.jellyfinService.getUserId());
uri.searchParams.set(
'DeviceId',
this.jellyfinService.getJellyfin().clientInfo.name,
);
uri.searchParams.set('MaxStreamingBitrate', `${bitrate}`);
uri.searchParams.set('Container', 'ogg,opus');
uri.searchParams.set('AudioCodec', 'opus');
uri.searchParams.set('TranscodingContainer', 'ts');
uri.searchParams.set('TranscodingProtocol', 'hls');
uri.searchParams.set('api_key', accessToken);
return uri.toString();
}
}

View File

@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common';
import { JellyfinService } from './jellyfin.service';
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api/playstate-api';
import { OnEvent } from '@nestjs/event-emitter';
@Injectable()
export class JellyinWebsocketService {
constructor(private readonly jellyfinClientManager: JellyfinService) {}
@OnEvent('clients.jellyfin.ready')
handleJellyfinBotReady() {
console.log('ready!');
this.openSocket();
}
private async openSocket() {
const systemApi = getPlaystateApi(this.jellyfinClientManager.getApi());
// TODO: Write socket playstate api to report playback progress
}
}

View File

@ -0,0 +1,40 @@
import { DiscordModule } from '@discord-nestjs/core';
import { Module } from '@nestjs/common';
import { DiscordClientModule } from '../clients/discord/discord.module';
import { JellyfinClientModule } from '../clients/jellyfin/jellyfin.module';
import { PlaybackModule } from '../playback/playback.module';
import { PlaylistCommand } from './playlist.command';
import { DisconnectCommand } from './disconnect.command';
import { HelpCommand } from './help.command';
import { PausePlaybackCommand } from './pause.command';
import { PlayItemCommand } from './play.comands';
import { PreviousTrackCommand } from './previous.command';
import { SkipTrackCommand } from './next.command';
import { StatusCommand } from './status.command';
import { StopPlaybackCommand } from './stop.command';
import { SummonCommand } from './summon.command';
@Module({
imports: [
DiscordModule.forFeature(),
JellyfinClientModule,
DiscordClientModule,
PlaybackModule,
],
controllers: [],
providers: [
HelpCommand,
StatusCommand,
PlaylistCommand,
DisconnectCommand,
PausePlaybackCommand,
SkipTrackCommand,
StopPlaybackCommand,
SummonCommand,
PlayItemCommand,
PreviousTrackCommand,
],
exports: [],
})
export class CommandModule {}

View File

@ -0,0 +1,35 @@
import { TransformPipe } from '@discord-nestjs/common';
import { Command, DiscordCommand, UsePipes } from '@discord-nestjs/core';
import { CommandInteraction } from 'discord.js';
import { DiscordMessageService } from '../clients/discord/discord.message.service';
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
import { GenericCustomReply } from '../models/generic-try-handler';
@Command({
name: 'disconnect',
description: 'Join your current voice channel',
})
@UsePipes(TransformPipe)
export class DisconnectCommand implements DiscordCommand {
constructor(
private readonly discordVoiceService: DiscordVoiceService,
private readonly discordMessageService: DiscordMessageService,
) {}
handler(interaction: CommandInteraction): GenericCustomReply {
const disconnect = this.discordVoiceService.disconnect();
if (!disconnect.success) {
return disconnect.reply;
}
return {
embeds: [
this.discordMessageService.buildMessage({
title: 'Disconnected from your channel',
}),
],
};
}
}

View File

@ -0,0 +1,44 @@
import { TransformPipe } from '@discord-nestjs/common';
import { Command, DiscordCommand, UsePipes } from '@discord-nestjs/core';
import { CommandInteraction } from 'discord.js';
import { DiscordMessageService } from '../clients/discord/discord.message.service';
import { GenericCustomReply } from '../models/generic-try-handler';
@Command({
name: 'help',
description: 'Get help if you&apos;re having problems with this bot',
})
@UsePipes(TransformPipe)
export class HelpCommand implements DiscordCommand {
constructor(private readonly discordMessageService: DiscordMessageService) {}
handler(commandInteraction: CommandInteraction): GenericCustomReply {
return {
embeds: [
this.discordMessageService.buildMessage({
title: 'Jellyfin Discord Bot',
description:
'Jellyfin Discord Bot is an open source and self-hosted Discord bot, that integrates with your Jellyfin Media server and enables you to playback music from your libraries. You can use the Discord Slash Commands to invoke bot commands.',
authorUrl: 'https://github.com/manuel-rw/jellyfin-discord-music-bot',
mixin(embedBuilder) {
return embedBuilder.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,
},
]);
},
}),
],
};
}
}

View File

@ -0,0 +1,40 @@
import { TransformPipe } from '@discord-nestjs/common';
import { Command, DiscordCommand, UsePipes } from '@discord-nestjs/core';
import { CommandInteraction, InteractionReplyOptions } from 'discord.js';
import { DiscordMessageService } from '../clients/discord/discord.message.service';
import { PlaybackService } from '../playback/playback.service';
@Command({
name: 'next',
description: 'Go to the next track in the playlist',
})
@UsePipes(TransformPipe)
export class SkipTrackCommand implements DiscordCommand {
constructor(
private readonly playbackService: PlaybackService,
private readonly discordMessageService: DiscordMessageService,
) {}
handler(
interactionCommand: CommandInteraction,
): InteractionReplyOptions | string {
if (!this.playbackService.nextTrack()) {
return {
embeds: [
this.discordMessageService.buildErrorMessage({
title: 'There is no next track',
}),
],
};
}
return {
embeds: [
this.discordMessageService.buildMessage({
title: 'Skipped to the next track',
}),
],
};
}
}

View File

@ -0,0 +1,32 @@
import { TransformPipe } from '@discord-nestjs/common';
import { Command, DiscordCommand, UsePipes } from '@discord-nestjs/core';
import { CommandInteraction, InteractionReplyOptions } from 'discord.js';
import { DiscordMessageService } from '../clients/discord/discord.message.service';
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
@Command({
name: 'pause',
description: 'Pause or resume the playback of the current track',
})
@UsePipes(TransformPipe)
export class PausePlaybackCommand implements DiscordCommand {
constructor(
private readonly discordVoiceService: DiscordVoiceService,
private readonly discordMessageService: DiscordMessageService,
) {}
handler(
commandInteraction: CommandInteraction,
): string | InteractionReplyOptions {
const shouldBePaused = this.discordVoiceService.togglePaused();
return {
embeds: [
this.discordMessageService.buildMessage({
title: shouldBePaused ? 'Paused' : 'Unpaused',
}),
],
};
}
}

View File

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

View File

@ -0,0 +1,78 @@
import { TransformPipe } from '@discord-nestjs/common';
import { Command, DiscordCommand, UsePipes } from '@discord-nestjs/core';
import { CommandInteraction } from 'discord.js';
import { DiscordMessageService } from '../clients/discord/discord.message.service';
import { GenericCustomReply } from '../models/generic-try-handler';
import { PlaybackService } from '../playback/playback.service';
import { Constants } from '../utils/constants';
import { trimStringToFixedLength } from '../utils/stringUtils';
import { formatMillisecondsAsHumanReadable } from '../utils/timeUtils';
@Command({
name: 'playlist',
description: 'Print the current track information',
})
@UsePipes(TransformPipe)
export class PlaylistCommand implements DiscordCommand {
constructor(
private readonly discordMessageService: DiscordMessageService,
private readonly playbackService: PlaybackService,
) {}
handler(interaction: CommandInteraction): GenericCustomReply {
const playList = this.playbackService.getPlaylist();
if (playList.tracks.length === 0) {
return {
embeds: [
this.discordMessageService.buildMessage({
title: 'Your Playlist',
description:
'You do not have any tracks in your playlist.\nUse the play command to add new tracks to your playlist',
}),
],
};
}
const tracklist = playList.tracks
.slice(0, 10)
.map((track, index) => {
const isCurrent = track.id === playList.activeTrack;
let point = this.getListPoint(isCurrent, index);
point += `**${trimStringToFixedLength(track.track.name, 30)}**`;
if (isCurrent) {
point += ' :loud_sound:';
}
point += '\n';
point += Constants.Design.InvisibleSpace.repeat(2);
point += 'Duration: ';
point += formatMillisecondsAsHumanReadable(
track.track.durationInMilliseconds,
);
return point;
})
.join('\n');
return {
embeds: [
this.discordMessageService.buildMessage({
title: 'Your Playlist',
description: `${tracklist}\n\nUse the /skip and /previous command to select a track`,
}),
],
};
}
private getListPoint(isCurrent: boolean, index: number) {
if (isCurrent) {
return `${index + 1}. `;
}
return `${index + 1}. `;
}
}

View File

@ -0,0 +1,40 @@
import { TransformPipe } from '@discord-nestjs/common';
import { Command, DiscordCommand, UsePipes } from '@discord-nestjs/core';
import { CommandInteraction, InteractionReplyOptions } from 'discord.js';
import { DiscordMessageService } from '../clients/discord/discord.message.service';
import { PlaybackService } from '../playback/playback.service';
@Command({
name: 'previous',
description: 'Go to the previous track',
})
@UsePipes(TransformPipe)
export class PreviousTrackCommand implements DiscordCommand {
constructor(
private readonly playbackService: PlaybackService,
private readonly discordMessageService: DiscordMessageService,
) {}
handler(
dcommandInteraction: CommandInteraction,
): InteractionReplyOptions | string {
if (!this.playbackService.previousTrack()) {
return {
embeds: [
this.discordMessageService.buildErrorMessage({
title: 'There is no previous track',
}),
],
};
}
return {
embeds: [
this.discordMessageService.buildMessage({
title: 'Went to previous track',
}),
],
};
}
}

View File

@ -0,0 +1,94 @@
import { TransformPipe } from '@discord-nestjs/common';
import {
Command,
DiscordCommand,
InjectDiscordClient,
UsePipes
} from '@discord-nestjs/core';
import {
Client,
CommandInteraction,
InteractionReplyOptions,
Status
} from 'discord.js';
import { formatDuration, intervalToDuration } from 'date-fns';
import { DiscordMessageService } from '../clients/discord/discord.message.service';
import { JellyfinService } from '../clients/jellyfin/jellyfin.service';
import { Constants } from '../utils/constants';
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
@Command({
name: 'status',
description: 'Display the current status for troubleshooting',
})
@UsePipes(TransformPipe)
export class StatusCommand implements DiscordCommand {
constructor(
@InjectDiscordClient()
private readonly client: Client,
private readonly discordMessageService: DiscordMessageService,
private readonly jellyfinService: JellyfinService,
) {}
async handler(
commandInteraction: CommandInteraction,
): Promise<string | InteractionReplyOptions> {
const ping = this.client.ws.ping;
const status = Status[this.client.ws.status];
const interval = intervalToDuration({
start: this.client.uptime,
end: 0,
});
const formattedDuration = formatDuration(interval);
const jellyfinSystemApi = getSystemApi(this.jellyfinService.getApi());
const jellyfinSystemInformation = await jellyfinSystemApi.getSystemInfo();
return {
embeds: [
this.discordMessageService.buildMessage({
title: 'Discord Bot Status',
mixin(embedBuilder) {
return embedBuilder.addFields([
{
name: 'Bot Version',
value: Constants.Metadata.Version,
inline: true,
},
{
name: 'Discord Bot Ping',
value: `${ping}ms`,
inline: true,
},
{
name: 'Discord Bot Status',
value: `${status}`,
inline: true,
},
{
name: 'Discord Bot Uptime',
value: `${formattedDuration}`,
inline: false,
},
{
name: 'Jellyfin Server Version',
value: jellyfinSystemInformation.data.Version ?? 'unknown',
inline: true,
},
{
name: 'Jellyfin Server Operating System',
value:
jellyfinSystemInformation.data.OperatingSystem ?? 'unknown',
inline: true,
},
]);
},
}),
],
};
}
}

View File

@ -0,0 +1,35 @@
import { TransformPipe } from '@discord-nestjs/common';
import { Command, DiscordCommand, UsePipes } from '@discord-nestjs/core';
import { CommandInteraction } from 'discord.js';
import { DiscordMessageService } from '../clients/discord/discord.message.service';
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
import { GenericCustomReply } from '../models/generic-try-handler';
import { PlaybackService } from '../playback/playback.service';
@Command({
name: 'stop',
description: 'Stop playback entirely and clear the current playlist',
})
@UsePipes(TransformPipe)
export class StopPlaybackCommand implements DiscordCommand {
constructor(
private readonly playbackService: PlaybackService,
private readonly discordMessageService: DiscordMessageService,
private readonly discordVoiceService: DiscordVoiceService,
) {}
handler(CommandInteraction: CommandInteraction): GenericCustomReply {
this.playbackService.clear();
this.discordVoiceService.stop(false);
return {
embeds: [
this.discordMessageService.buildMessage({
title: 'Playlist cleared',
description:
'Playback was stopped and your playlist has been cleared',
}),
],
};
}
}

View File

@ -0,0 +1,43 @@
import { TransformPipe } from '@discord-nestjs/common';
import { Command, DiscordCommand, UsePipes } from '@discord-nestjs/core';
import { Logger } from '@nestjs/common';
import { CommandInteraction, GuildMember } from 'discord.js';
import { DiscordMessageService } from '../clients/discord/discord.message.service';
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
import { GenericCustomReply } from '../models/generic-try-handler';
@Command({
name: 'summon',
description: 'Join your current voice channel',
})
@UsePipes(TransformPipe)
export class SummonCommand implements DiscordCommand {
private readonly logger = new Logger(SummonCommand.name);
constructor(
private readonly discordVoiceService: DiscordVoiceService,
private readonly discordMessageService: DiscordMessageService,
) {}
handler(interaction: CommandInteraction): GenericCustomReply {
const guildMember = interaction.member as GuildMember;
const tryResult =
this.discordVoiceService.tryJoinChannelAndEstablishVoiceConnection(
guildMember,
);
if (!tryResult.success) {
return tryResult.reply;
}
return {
embeds: [
this.discordMessageService.buildMessage({
title: 'Joined your voicehannel',
}),
],
};
}
}

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

9
src/main.ts Normal file
View File

@ -0,0 +1,9 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
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

@ -0,0 +1,11 @@
import { InteractionReplyOptions } from 'discord.js';
export interface GenericTryHandler {
success: boolean;
reply: GenericCustomReply;
}
export type GenericCustomReply =
| string
| InteractionReplyOptions
| Promise<string | InteractionReplyOptions>;

View File

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

View File

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

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PlaybackService } from './playback.service';
@Module({
imports: [],
controllers: [],
providers: [PlaybackService],
exports: [PlaybackService],
})
export class PlaybackModule {}

View File

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

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

4
src/types/colors.ts Normal file
View File

@ -0,0 +1,4 @@
import { RGBTuple } from 'discord.js';
export const DefaultJellyfinColor: RGBTuple = [119, 116, 204];
export const ErrorJellyfinColor: RGBTuple = [242, 33, 95];

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

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

9
src/types/playlist.ts Normal file
View File

@ -0,0 +1,9 @@
import { Track } from './track';
export interface Playlist {
tracks: {
id: string;
track: Track;
}[];
activeTrack: string | null;
}

6
src/types/track.ts Normal file
View File

@ -0,0 +1,6 @@
export interface Track {
jellyfinId: string;
name: string;
durationInMilliseconds: number;
streamUrl: 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,
};

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

@ -0,0 +1,22 @@
export const Constants = {
Metadata: {
Version: '0.0.1',
ApplicationName: 'Discord Jellyfin Music Bot',
},
Links: {
SourceCode: 'https://github.com/manuel-rw/jellyfin-discord-music-bot/',
ReportIssue:
'https://github.com/manuel-rw/jellyfin-discord-music-bot/issues/new/choose',
},
Design: {
InvisibleSpace: '\u1CBC',
Icons: {
JellyfinLogo:
'https://github.com/manuel-rw/jellyfin-discord-music-bot/blob/nestjs-migration/images/icons/jellyfin-icon-squared.png?raw=true',
SuccessIcon:
'https://github.com/manuel-rw/jellyfin-discord-music-bot/blob/nestjs-migration/images/icons/circle-check.png?raw=true',
ErrorIcon:
'https://github.com/manuel-rw/jellyfin-discord-music-bot/blob/nestjs-migration/images/icons/alert-circle.png?raw=true',
},
},
};

9
src/utils/stringUtils.ts Normal file
View File

@ -0,0 +1,9 @@
export const trimStringToFixedLength = (value: string, maxLength: number) => {
if (maxLength < 1) {
throw new Error('max length must be positive');
}
return value.length > maxLength
? value.substring(0, maxLength - 3) + '...'
: value;
};

11
src/utils/timeUtils.ts Normal file
View File

@ -0,0 +1,11 @@
import { formatDuration, intervalToDuration } from 'date-fns';
export const formatMillisecondsAsHumanReadable = (milliseconds: number) => {
const duration = formatDuration(
intervalToDuration({
start: milliseconds,
end: 0,
}),
);
return duration;
};

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"]
}

22
tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"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,
"useDefineForClassFields": true
}
}

10019
yarn.lock

File diff suppressed because it is too large Load Diff