mirror of
https://github.com/informaticker/discord-jellyfin-bot.git
synced 2024-10-18 11:25:04 +02:00
♻️ Migrate to NestJS
This commit is contained in:
parent
cc6fd1e2d4
commit
6c5b282bc0
25
.eslintrc.js
Normal file
25
.eslintrc.js
Normal file
@ -0,0 +1,25 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir : __dirname,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
};
|
43
.gitignore
vendored
43
.gitignore
vendored
@ -1,4 +1,39 @@
|
||||
node_modules
|
||||
package-lock.json
|
||||
config.json
|
||||
.prettierrc
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
.yarn
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# Secrets
|
||||
*.env
|
4
.prettierrc
Normal file
4
.prettierrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
1
.yarnrc.yml
Normal file
1
.yarnrc.yml
Normal file
@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
160
README.md
160
README.md
@ -1,145 +1,63 @@
|
||||
<p align="center">
|
||||
<img src="https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/jellyfin.png" alt="Jellyfin Logo" width="80" height="80">
|
||||
<h1 align="center">Jellyfin Discord Music Bot</h1>
|
||||
<div align="center">
|
||||
<span>A fork of the <a href="https://github.com/KGT1/jellyfin-discord-music-bot">original project</a> with improved readability and stability, compatible with Jellyfin 10.8.x</span>
|
||||
</div>
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://github.com/walkxcode/dashboard-icons/blob/main/png/jellyfin.png?raw=true" width="200" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
|
||||
# ✨ Features
|
||||
- Simple Discord Bot that hooks into the [Jellyfin](http://github.com/jellyfin/jellyfin) API of your instance
|
||||
- Request, pause and play songs directly from your Discord Server
|
||||
- Interactive Media control message to control playback
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
|
||||
# 🦾 About this fork
|
||||
The original version is decent for Jellyfin 10.6.x and before. After the breaking changes of 10.7 and 10.8, users were unable to stream music from their Jellyfin.
|
||||
For this reason, I made this fork to address those changes to the API and improve the bot with my own ideas / features. Please check out the original project by [KGT1](https://github.com/KGT1).
|
||||
<p align="center">A simple <a href="https://discord.com" target="_blank">Discord</a> bot that enables you to broadcast your <a href="https://jellyfin.org/" target="_blank">Jellyfin Media Server</a> music collection to voice channels.</p>
|
||||
|
||||
I will gradually update documentation & code of the bot. Please wait patiently.
|
||||
<p align="center">
|
||||
<small>Thanky you KGT1 for starting this project! This is a fork of their original repository and re-uses some of their code.</small>
|
||||
</p>
|
||||
|
||||
<br/><br/><br/><br/><br/><br/><hr/><br/>
|
||||
|
||||
## Original README from https://github.com/KGT1/jellyfin-discord-music-bot
|
||||
<br/>
|
||||
<br/>
|
||||
<hr/>
|
||||
<br/>
|
||||
|
||||
Jellyfin Discord Music Bot is a Discord Bot for the [Jellyfin Media Server!](http://github.com/jellyfin/jellyfin)
|
||||
|
||||
### Capabilities
|
||||
## ✨ Features
|
||||
|
||||
#### Play to
|
||||
- Leighweight and extendable using the [Nest](https://github.com/nestjs/nest) framework
|
||||
- Easy ussage with Discord commands system (eg. ``/play``, ``/pause``, ...)
|
||||
- Fast configuration via environment variables
|
||||
- Typesafe for faster and easier development
|
||||
|
||||
Just `summon` the Bot into your Channel, than choose the Bot in Jellyfin as the Device you want to cast to
|
||||
|
||||
![Image to Discord Play to Window](img/playtowindow.png)
|
||||
|
||||
and start playing you favourite Music
|
||||
|
||||
#### Interactive Play Message
|
||||
|
||||
When you start playing something you can easily controll the Bot with just clicking on the Buttons under the Play Message
|
||||
|
||||
![Image to Interactive Play Message](img/discordplaymessage.png)
|
||||
|
||||
#### Commands
|
||||
|
||||
Beware that you'll always need to add your prefix(default: ?) in front of the command.
|
||||
|
||||
Command | Description
|
||||
------------ | -------------
|
||||
summon | Join the channel the author of the message(now you can cast to the Bot from within Jellyfin)
|
||||
disconnect | Disconnect from all current Voice Channels
|
||||
play | Play the following item(can be the name of the song or the Stream URL)
|
||||
add | Add the following item to the current playlist
|
||||
pause/resume | Pause/Resume audio
|
||||
seek | Where to Seek to in seconds or MM:SS
|
||||
skip | Skip this Song
|
||||
spawn | Spawns an Interactive Play Controller
|
||||
help | Display the help message
|
||||
|
||||
#### Limitations
|
||||
- No Playlist Repeat Mode.
|
||||
- Multi Server support.
|
||||
- [Playing Video Content](https://github.com/discordjs/discord.js/issues/4116) (if Discord ever adds this, I'll implement it into this Bot)
|
||||
|
||||
### Getting Started
|
||||
You'll need a Discord Application for this Bot to work, as you will host it yourself.
|
||||
|
||||
[Generate an Api and bot here](https://discord.com/developers/applications/).
|
||||
|
||||
Click New Application.
|
||||
|
||||
![image](https://user-images.githubusercontent.com/20715731/97124506-bba00080-1706-11eb-820a-035039484ca2.png)
|
||||
|
||||
The Name of the application will be the bot's name.
|
||||
|
||||
![image](https://user-images.githubusercontent.com/20715731/97124528-d2deee00-1706-11eb-8a05-8b0542e1213a.png)
|
||||
|
||||
Go to the Bot tab.
|
||||
|
||||
![image](https://user-images.githubusercontent.com/20715731/97124557-ef7b2600-1706-11eb-8fed-2373df9a1eb7.png)
|
||||
|
||||
Generate the bot, and grab the token. Also, recommend making the bot private.
|
||||
|
||||
![image](https://user-images.githubusercontent.com/20715731/97124639-484abe80-1707-11eb-92f9-1182aad3d2d2.png)
|
||||
|
||||
Go to the OAuth2 page, click Bot Scope to get the url authorization link.
|
||||
|
||||
![image](https://user-images.githubusercontent.com/20715731/97124754-b68f8100-1707-11eb-9e16-f84401d108bf.png)
|
||||
|
||||
Authorize your room!
|
||||
|
||||
![image](https://user-images.githubusercontent.com/20715731/97124818-08380b80-1708-11eb-944a-f96395dcf6c1.png)
|
||||
|
||||
Next, join a voice channel and connect your bot with ?summon. This will connect your bot to the voice channel you're in and will create the device profile in Jellyfin.
|
||||
|
||||
![Image to Discord Play to Window](img/playtowindow.png)
|
||||
|
||||
From within Jellyfin, start playing content or from within Discord, use the bot commands to start enjoying music!
|
||||
|
||||
For official documentation to creating a bot.
|
||||
|
||||
[How to retrieve your token](https://discordjs.guide/preparations/setting-up-a-bot-application.html#creating-your-bot)
|
||||
|
||||
[How to invite the Bot to your server](https://discordjs.guide/preparations/adding-your-bot-to-servers.html#bot-invite-links)
|
||||
|
||||
### The simplest way to get started is using Docker:
|
||||
## 🚀 Installation
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name jellyfin-discord-music-bot \
|
||||
-e DISCORD_PREFIX="?" \
|
||||
-e DISCORD_TOKEN="yourtokengoeshere" \
|
||||
-e JELLYFIN_SERVER_ADDRESS="https://jellyfin.DOMAIN" \
|
||||
-e JELLYFIN_USERNAME="" \
|
||||
-e JELLYFIN_PASSWORD="" \
|
||||
-e JELLYFIN_APP_NAME="Jellyfin Discord Music Bot" \
|
||||
-e MESSAGE_UPDATE_INTERVAL="2000" \
|
||||
--restart unless-stopped \
|
||||
kgt1/jellyfin-discord-music-bot
|
||||
$ git clone https://github.com/manuel-rw/jellyfin-discord-music-bot.git
|
||||
$ cd jellyfin-discord-music-bot/
|
||||
$ yarn
|
||||
$ yarn start:prod
|
||||
```
|
||||
|
||||
MESSAGE_UPDATE_INTERVAL is the amount of time in ms the play message gets updated with the current time
|
||||
> Docker container comming soon
|
||||
|
||||
#### Alternatively you can run the Application natively with NodeJS:
|
||||
## 💻 Development
|
||||
|
||||
Dependencies:
|
||||
I'm open to any contributions to this project. You can start contributing using the following commands, after executing the installation commands:
|
||||
|
||||
- npm 6.14.6
|
||||
- NodeJS v12.18.3
|
||||
- ffmpeg 4.2.4
|
||||
```bash
|
||||
git clone https://github.com/kgt1/jellyfin-discord-music-bot.git
|
||||
cd jellyfin-discord-music-bot
|
||||
npm install
|
||||
# development
|
||||
$ npm run start
|
||||
|
||||
# watch mode
|
||||
$ npm run start:dev
|
||||
|
||||
# production mode
|
||||
$ npm run start:prod
|
||||
```
|
||||
edit config.json and add your token,server-address etc.
|
||||
|
||||
```bash
|
||||
npm run start
|
||||
```
|
||||
# unit tests
|
||||
$ npm run test
|
||||
|
||||
# e2e tests
|
||||
$ npm run test:e2e
|
||||
|
||||
### How to build
|
||||
```
|
||||
git clone https://github.com/kgt1/jellyfin-discord-music-bot.git
|
||||
cd jellyfin-discord-music-bot
|
||||
docker build -t YOUR_IMAGE_NAME .
|
||||
# test coverage
|
||||
$ npm run test:cov
|
||||
```
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 33 KiB |
Binary file not shown.
Before Width: | Height: | Size: 19 KiB |
5
nest-cli.json
Normal file
5
nest-cli.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
4088
package-lock.json
generated
Normal file
4088
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
106
package.json
106
package.json
@ -1,48 +1,78 @@
|
||||
{
|
||||
"name": "jellyfin-discord-music-bot",
|
||||
"version": "0.0.1",
|
||||
"description": "Jellyfin Discord Music Bot is a Discord Bot for the Jellyfin Media Server!",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"start": "node src/index.js",
|
||||
"postinstall": "npx patch-package",
|
||||
"lint": "npx eslint src/ & npx eslint parseENV.js"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/KGT1/jellyfin-discord-music-bot.git"
|
||||
},
|
||||
"keywords": [
|
||||
"Jellyfin",
|
||||
"Discord",
|
||||
"Discord-Bot"
|
||||
],
|
||||
"author": "KGT1",
|
||||
"description": "",
|
||||
"author": "manuel-rw",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/KGT1/jellyfin-discord-music-bot/issues"
|
||||
"scripts": {
|
||||
"prebuild": "rimraf dist",
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"homepage": "https://github.com/KGT1/jellyfin-discord-music-bot#readme",
|
||||
"dependencies": {
|
||||
"@discordjs/opus": "^0.3.2",
|
||||
"chalk": "^4.1.0",
|
||||
"discord.js": "^12.3.1",
|
||||
"jellyfin-apiclient": "1.7.0",
|
||||
"loglevel": "^1.7.1",
|
||||
"loglevel-plugin-prefix": "^0.8.4",
|
||||
"node-fetch": "^2.6.0",
|
||||
"nodejs": "0.0.0",
|
||||
"window": "^4.2.7",
|
||||
"ws": "^7.3.1"
|
||||
"@discordjs/opus": "^0.9.0",
|
||||
"@jellyfin/sdk": "^0.7.0",
|
||||
"@nestjs/common": "^9.0.0",
|
||||
"@nestjs/config": "^2.2.0",
|
||||
"@nestjs/core": "^9.0.0",
|
||||
"@nestjs/event-emitter": "^1.3.1",
|
||||
"@nestjs/platform-express": "^9.0.0",
|
||||
"discord.js": "^14.7.1",
|
||||
"jellyfin-apiclient": "^1.10.0",
|
||||
"joi": "^17.7.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.2",
|
||||
"rxjs": "^7.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"patch-package": "^6.4.7",
|
||||
"eslint": "^7.9.0",
|
||||
"eslint-config-standard": "^14.1.1",
|
||||
"eslint-plugin-import": "^2.22.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-promise": "^4.2.1",
|
||||
"eslint-plugin-standard": "^4.0.1"
|
||||
"@nestjs/cli": "^9.0.0",
|
||||
"@nestjs/schematics": "^9.0.0",
|
||||
"@nestjs/testing": "^9.0.0",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/jest": "28.1.8",
|
||||
"@types/node": "^16.0.0",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
"eslint": "^8.0.1",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"jest": "28.1.3",
|
||||
"prettier": "^2.3.2",
|
||||
"source-map-support": "^0.5.20",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "28.0.8",
|
||||
"ts-loader": "^9.2.3",
|
||||
"ts-node": "^10.0.0",
|
||||
"tsconfig-paths": "4.1.0",
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because one or more lines are too long
@ -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;
|
22
src/app.controller.spec.ts
Normal file
22
src/app.controller.spec.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
12
src/app.controller.ts
Normal file
12
src/app.controller.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
}
|
31
src/app.module.ts
Normal file
31
src/app.module.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { DiscordClientModule } from './clients/discord/discord.module';
|
||||
import { CommandHandlerModule } from './commands/handler/command-handler.module';
|
||||
import { JellyfinClientModule } from './clients/jellyfin/jellyfin.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
validationSchema: Joi.object({
|
||||
DISCORD_CLIENT_TOKEN: Joi.string().required(),
|
||||
JELLYFIN_SERVER_ADDRESS: Joi.string().required(),
|
||||
JELLYFIN_AUTHENTICATION_USERNAME: Joi.string().required(),
|
||||
JELLYFIN_AUTHENTICATION_PASSWORD: Joi.string().required(),
|
||||
}),
|
||||
}),
|
||||
EventEmitterModule.forRoot(),
|
||||
DiscordClientModule,
|
||||
JellyfinClientModule,
|
||||
CommandHandlerModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
9
src/app.service.ts
Normal file
9
src/app.service.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
constructor() {}
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
22
src/clients/discord/discord.module.ts
Normal file
22
src/clients/discord/discord.module.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Module, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
|
||||
import { DiscordService } from "./discord.service";
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [],
|
||||
providers: [DiscordService],
|
||||
exports: [DiscordService],
|
||||
})
|
||||
export class DiscordClientModule implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
constructor(private discordService: DiscordService) {}
|
||||
onModuleDestroy() {
|
||||
this.discordService.destroyClient();
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
this.discordService.initializeClient();
|
||||
this.discordService.registerEventHandlers();
|
||||
this.discordService.connectAndLogin();
|
||||
}
|
||||
}
|
46
src/clients/discord/discord.service.ts
Normal file
46
src/clients/discord/discord.service.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
|
||||
import { ActivityType, Client } from 'discord.js';
|
||||
|
||||
@Injectable()
|
||||
export class DiscordService {
|
||||
private readonly logger = new Logger(DiscordService.name);
|
||||
private client: Client;
|
||||
|
||||
constructor(private eventEmitter: EventEmitter2) {}
|
||||
|
||||
initializeClient() {
|
||||
this.client = new Client({
|
||||
intents: ['Guilds', 'GuildMessages', 'MessageContent'],
|
||||
});
|
||||
this.logger.debug('Initialized Discord client');
|
||||
}
|
||||
|
||||
connectAndLogin() {
|
||||
this.client.login(process.env.DISCORD_CLIENT_TOKEN);
|
||||
}
|
||||
|
||||
registerEventHandlers() {
|
||||
this.client.on('ready', () => {
|
||||
this.logger.debug(`Connected as '${this.client.user.tag}' and ready!`);
|
||||
this.eventEmitter.emit('client.discord.ready');
|
||||
});
|
||||
|
||||
this.client.on('messageCreate', async (message) => {
|
||||
if (message.author.bot) {
|
||||
return;
|
||||
}
|
||||
|
||||
await message.channel.send('nice');
|
||||
});
|
||||
}
|
||||
|
||||
destroyClient() {
|
||||
this.client.destroy();
|
||||
}
|
||||
|
||||
getClient() {
|
||||
return this.client;
|
||||
}
|
||||
}
|
22
src/clients/jellyfin/jellyfin.module.ts
Normal file
22
src/clients/jellyfin/jellyfin.module.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Module, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
|
||||
import { JellyfinService } from "./jellyfin.service";
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [],
|
||||
providers: [JellyfinService],
|
||||
exports: [],
|
||||
})
|
||||
export class JellyfinClientModule implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
constructor(private jellyfinService: JellyfinService) {}
|
||||
|
||||
onModuleDestroy() {
|
||||
this.jellyfinService.destroy();
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
this.jellyfinService.init();
|
||||
this.jellyfinService.authenticate();
|
||||
}
|
||||
}
|
55
src/clients/jellyfin/jellyfin.service.ts
Normal file
55
src/clients/jellyfin/jellyfin.service.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { Api, Jellyfin } from '@jellyfin/sdk';
|
||||
|
||||
import { Constants } from 'src/utils/constants';
|
||||
|
||||
@Injectable()
|
||||
export class JellyfinService {
|
||||
private readonly logger = new Logger(JellyfinService.name);
|
||||
private jellyfin: Jellyfin;
|
||||
private api: Api;
|
||||
|
||||
constructor() {}
|
||||
|
||||
init() {
|
||||
this.jellyfin = new Jellyfin({
|
||||
clientInfo: {
|
||||
name: Constants.Metadata.ApplicationName,
|
||||
version: Constants.Metadata.Version,
|
||||
},
|
||||
deviceInfo: {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
},
|
||||
});
|
||||
|
||||
this.api = this.jellyfin.createApi(process.env.JELLYFIN_SERVER_ADDRESS);
|
||||
this.logger.debug('Created Jellyfin Client and Api');
|
||||
}
|
||||
|
||||
authenticate() {
|
||||
this.api
|
||||
.authenticateUserByName(
|
||||
process.env.JELLYFIN_AUTHENTICATION_USERNAME,
|
||||
process.env.JELLYFIN_AUTHENTICATION_PASSWORD,
|
||||
)
|
||||
.then((response) => {
|
||||
this.logger.debug(
|
||||
`Connected using user '${response.data.SessionInfo.UserId}'`,
|
||||
);
|
||||
}).catch((test) => {
|
||||
this.logger.error(test);
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.api === undefined) {
|
||||
this.logger.warn(
|
||||
'Jellyfin Api Client was unexpectitly undefined. Graceful destroy has failed',
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.api.logout();
|
||||
}
|
||||
}
|
6
src/commands/abstractCommand.ts
Normal file
6
src/commands/abstractCommand.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
|
||||
export abstract class Command {
|
||||
abstract builder(): SlashCommandBuilder;
|
||||
abstract execute(): void;
|
||||
}
|
10
src/commands/handler/command-handler.module.ts
Normal file
10
src/commands/handler/command-handler.module.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DiscordClientModule } from '../../clients/discord/discord.module';
|
||||
import { CommandHandlerService } from './command-handler.service';
|
||||
|
||||
@Module({
|
||||
imports: [DiscordClientModule],
|
||||
controllers: [],
|
||||
providers: [CommandHandlerService],
|
||||
})
|
||||
export class CommandHandlerModule {}
|
107
src/commands/handler/command-handler.service.ts
Normal file
107
src/commands/handler/command-handler.service.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import { EmbedBuilder } from '@discordjs/builders';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import {
|
||||
ApplicationCommand,
|
||||
SlashCommandBuilder,
|
||||
SlashCommandSubcommandBuilder,
|
||||
} from 'discord.js';
|
||||
import { DiscordService } from 'src/clients/discord/discord.service';
|
||||
import { Command } from '../abstractCommand';
|
||||
|
||||
@Injectable()
|
||||
export class CommandHandlerService {
|
||||
private logger: Logger = new Logger(CommandHandlerService.name);
|
||||
|
||||
constructor(private discordService: DiscordService) {}
|
||||
|
||||
@OnEvent('client.discord.ready')
|
||||
async handleOnDiscordClientReady() {
|
||||
var commands = [
|
||||
new SlashCommandBuilder()
|
||||
.setName('play')
|
||||
.setDescription('Immideatly play a track')
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName('track')
|
||||
.setDescription('the track name')
|
||||
.setRequired(true),
|
||||
),
|
||||
new SlashCommandBuilder()
|
||||
.setName('summon')
|
||||
.setDescription('Join your current voice channel'),
|
||||
new SlashCommandBuilder()
|
||||
.setName('disconnect')
|
||||
.setDescription('Disconnect from the current voice channel'),
|
||||
new SlashCommandBuilder()
|
||||
.setName('enqueue')
|
||||
.setDescription('Enqueue a track to the current playlist')
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName('track')
|
||||
.setDescription('the track name')
|
||||
.setRequired(true),
|
||||
),
|
||||
new SlashCommandBuilder()
|
||||
.setName('current')
|
||||
.setDescription('Print the current track information'),
|
||||
new SlashCommandBuilder()
|
||||
.setName('pause')
|
||||
.setDescription('Pause or resume the playback of the current track'),
|
||||
new SlashCommandBuilder()
|
||||
.setName('skip')
|
||||
.setDescription('Skip the current track'),
|
||||
new SlashCommandBuilder()
|
||||
.setName('stop')
|
||||
.setDescription(
|
||||
'Stop playback entirely and clear the current playlist',
|
||||
),
|
||||
new SlashCommandBuilder()
|
||||
.setName('help')
|
||||
.setDescription('Get help for this Discord Bot'),
|
||||
];
|
||||
|
||||
await this.discordService
|
||||
.getClient()
|
||||
.application.commands.set(commands.map((x) => x.toJSON()));
|
||||
|
||||
this.discordService
|
||||
.getClient()
|
||||
.on('interactionCreate', async (interaction) => {
|
||||
if (!interaction.isChatInputCommand()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({
|
||||
embeds: [
|
||||
new EmbedBuilder()
|
||||
.setAuthor({
|
||||
name: 'Jellyfin Discord Bot',
|
||||
iconURL:
|
||||
'https://github.com/walkxcode/dashboard-icons/blob/main/png/jellyfin.png?raw=true',
|
||||
url: 'https://github.com/manuel-rw/jellyfin-discord-music-bot',
|
||||
})
|
||||
.setTitle('Help Information')
|
||||
.setDescription(
|
||||
'Jellyfin Discord Music bot is an easy way to broadcast your music collection to a Discord voicechannel.',
|
||||
)
|
||||
.addFields([
|
||||
{
|
||||
name: 'Report an issue',
|
||||
value:
|
||||
'https://github.com/manuel-rw/jellyfin-discord-music-bot/issues/new/choose',
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: 'Source code',
|
||||
value:
|
||||
'https://github.com/manuel-rw/jellyfin-discord-music-bot',
|
||||
inline: true,
|
||||
},
|
||||
])
|
||||
.toJSON(),
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
@ -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,
|
||||
};
|
@ -1,13 +0,0 @@
|
||||
var audioDispatcher;
|
||||
|
||||
function setAudioDispatcher(par) {
|
||||
audioDispatcher = par;
|
||||
}
|
||||
function getAudioDispatcher() {
|
||||
return audioDispatcher;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setAudioDispatcher,
|
||||
getAudioDispatcher,
|
||||
};
|
64
src/index.js
64
src/index.js
@ -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);
|
||||
}
|
@ -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
|
||||
};
|
@ -1,29 +0,0 @@
|
||||
const { ApiClient, Events } = require("jellyfin-apiclient");
|
||||
const CONFIG = require("../config.json");
|
||||
const os = require("os");
|
||||
|
||||
var jellyfinClient;
|
||||
|
||||
function init() {
|
||||
jellyfinClient = new ApiClient(
|
||||
CONFIG["server-address"],
|
||||
CONFIG["jellyfin-app-name"],
|
||||
"0.0.1",
|
||||
os.hostname(),
|
||||
os.hostname()
|
||||
);
|
||||
}
|
||||
|
||||
function getJellyfinClient() {
|
||||
return jellyfinClient;
|
||||
}
|
||||
|
||||
function getJellyfinEvents() {
|
||||
return Events;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getJellyfinClient,
|
||||
getJellyfinEvents,
|
||||
init,
|
||||
};
|
8
src/main.ts
Normal file
8
src/main.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
await app.listen(3000);
|
||||
}
|
||||
bootstrap();
|
@ -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,
|
||||
};
|
@ -1,535 +0,0 @@
|
||||
const interactivemsghandler = require("./interactivemsghandler");
|
||||
const CONFIG = require("../config.json");
|
||||
const discordclientmanager = require("./discordclientmanager");
|
||||
const log = require("loglevel");
|
||||
|
||||
const {
|
||||
getAudioDispatcher,
|
||||
setAudioDispatcher
|
||||
} = require("./dispachermanager");
|
||||
const { ticksToSeconds } = require("./util");
|
||||
|
||||
// this whole thing should be a class but its probably too late now.
|
||||
|
||||
var currentPlayingPlaylist;
|
||||
var currentPlayingPlaylistIndex;
|
||||
var isPaused;
|
||||
var isRepeat;
|
||||
var _disconnectOnFinish;
|
||||
var _seek;
|
||||
|
||||
const jellyfinClientManager = require("./jellyfinclientmanager");
|
||||
const { VoiceConnection } = require("discord.js");
|
||||
|
||||
function streamURLbuilder(itemID, bitrate) {
|
||||
// so the server transcodes. Seems appropriate as it has the source file.(doesnt yet work i dont know why)
|
||||
const supportedCodecs = "opus";
|
||||
const supportedContainers = "ogg,opus";
|
||||
return `${jellyfinClientManager
|
||||
.getJellyfinClient()
|
||||
.serverAddress()}/Audio/${itemID}/universal?UserId=${jellyfinClientManager
|
||||
.getJellyfinClient()
|
||||
.getCurrentUserId()}&DeviceId=${jellyfinClientManager
|
||||
.getJellyfinClient()
|
||||
.deviceId()}&MaxStreamingBitrate=${bitrate}&Container=${supportedContainers}&AudioCodec=${supportedCodecs}&api_key=${jellyfinClientManager
|
||||
.getJellyfinClient()
|
||||
.accessToken()}&TranscodingContainer=ts&TranscodingProtocol=hls`;
|
||||
}
|
||||
|
||||
function startPlaying(
|
||||
voiceconnection = discordclientmanager
|
||||
.getDiscordClient()
|
||||
.user.client.voice.connections.first(),
|
||||
itemIDPlaylist = currentPlayingPlaylist,
|
||||
playlistIndex = currentPlayingPlaylistIndex,
|
||||
seekTo,
|
||||
disconnectOnFinish = _disconnectOnFinish
|
||||
) {
|
||||
log.debug(
|
||||
"Start playing",
|
||||
itemIDPlaylist[playlistIndex],
|
||||
"with index",
|
||||
playlistIndex,
|
||||
"of list with length of",
|
||||
itemIDPlaylist.length,
|
||||
"in",
|
||||
voiceconnection && voiceconnection.channel
|
||||
? '"' +
|
||||
voiceconnection.channel.name +
|
||||
'" (' +
|
||||
voiceconnection.channel.id +
|
||||
")"
|
||||
: "an unknown voice channel"
|
||||
);
|
||||
|
||||
isPaused = false;
|
||||
currentPlayingPlaylist = itemIDPlaylist;
|
||||
currentPlayingPlaylistIndex = playlistIndex;
|
||||
_disconnectOnFinish = disconnectOnFinish;
|
||||
_seek = seekTo * 1000;
|
||||
updatePlayMessage();
|
||||
|
||||
async function playasync() {
|
||||
const url = streamURLbuilder(
|
||||
itemIDPlaylist[playlistIndex],
|
||||
voiceconnection.channel.bitrate
|
||||
);
|
||||
setAudioDispatcher(
|
||||
voiceconnection.play(url, {
|
||||
seek: seekTo
|
||||
})
|
||||
);
|
||||
if (seekTo) {
|
||||
jellyfinClientManager
|
||||
.getJellyfinClient()
|
||||
.reportPlaybackProgress(getProgressPayload());
|
||||
} else {
|
||||
jellyfinClientManager.getJellyfinClient().reportPlaybackStart({
|
||||
userID: `${jellyfinClientManager.getJellyfinClient().getCurrentUserId()}`,
|
||||
itemID: `${itemIDPlaylist[playlistIndex]}`,
|
||||
canSeek: true,
|
||||
playSessionId: getPlaySessionId(),
|
||||
playMethod: getPlayMethod()
|
||||
});
|
||||
}
|
||||
|
||||
getAudioDispatcher().on("finish", () => {
|
||||
// report playback stop and start the same index again
|
||||
if (isRepeat) {
|
||||
reportPlaybackStoppedAndStartPlaying(
|
||||
voiceconnection,
|
||||
currentPlayingPlaylistIndex
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPlayingPlaylist.length < playlistIndex) {
|
||||
if (disconnectOnFinish) {
|
||||
stop(voiceconnection, currentPlayingPlaylist[playlistIndex - 1]);
|
||||
return;
|
||||
}
|
||||
|
||||
stop(undefined, currentPlayingPlaylist[playlistIndex - 1]);
|
||||
return;
|
||||
}
|
||||
|
||||
// play the next song in the playlist
|
||||
reportPlaybackStoppedAndStartPlaying(
|
||||
voiceconnection,
|
||||
currentPlayingPlaylistIndex + 1
|
||||
);
|
||||
});
|
||||
}
|
||||
playasync().catch((rsn) => {
|
||||
console.error(rsn);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {VoiceConnection} voiceconnection - The voiceConnection where the bot should play
|
||||
* @param {number} playlistIndex - The target playlist index
|
||||
* @param {any} disconnectOnFinish
|
||||
*/
|
||||
const reportPlaybackStoppedAndStartPlaying = (
|
||||
voiceconnection,
|
||||
playlistIndex,
|
||||
disconnectOnFinish
|
||||
) => {
|
||||
const stopPayload = getStopPayload();
|
||||
|
||||
log.debug(
|
||||
"Repeat and sending following payload as reportPlaybackStopped to the server: ",
|
||||
stopPayload
|
||||
);
|
||||
|
||||
jellyfinClientManager.getJellyfinClient().reportPlaybackStopped(stopPayload);
|
||||
startPlaying(voiceconnection, undefined, playlistIndex, 0, disconnectOnFinish);
|
||||
};
|
||||
|
||||
async function spawnPlayMessage(message) {
|
||||
if (!message.channel) {
|
||||
log.error("Unable to send play message in channel");
|
||||
log.debug(message);
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug(
|
||||
"Sending play message to channel",
|
||||
message.channel.name,
|
||||
"(" + message.channel.id + ")"
|
||||
);
|
||||
|
||||
const itemIdDetails = await jellyfinClientManager
|
||||
.getJellyfinClient()
|
||||
.getItem(
|
||||
jellyfinClientManager.getJellyfinClient().getCurrentUserId(),
|
||||
getItemId()
|
||||
);
|
||||
const imageURL = await jellyfinClientManager
|
||||
.getJellyfinClient()
|
||||
.getImageUrl(itemIdDetails.AlbumId || getItemId(), { type: "Primary" });
|
||||
try {
|
||||
interactivemsghandler.init(
|
||||
message,
|
||||
itemIdDetails.Name,
|
||||
itemIdDetails.Artists[0] || "VA",
|
||||
imageURL,
|
||||
`${jellyfinClientManager
|
||||
.getJellyfinClient()
|
||||
.serverAddress()}/web/index.html#!/details?id=${itemIdDetails.AlbumId}`,
|
||||
itemIdDetails.RunTimeTicks,
|
||||
ticksToSeconds(getPostitionTicks()) > 10 ? previousTrack : seek,
|
||||
playPause,
|
||||
() => {
|
||||
stop(
|
||||
_disconnectOnFinish
|
||||
? discordclientmanager
|
||||
.getDiscordClient()
|
||||
.user.client.voice.connections.first()
|
||||
: undefined
|
||||
);
|
||||
},
|
||||
nextTrack,
|
||||
() => {
|
||||
setIsRepeat(!isRepeat);
|
||||
},
|
||||
currentPlayingPlaylist.length
|
||||
);
|
||||
if (typeof CONFIG["interactive-seek-bar-update-intervall"] === "number") {
|
||||
interactivemsghandler.startUpate(getPostitionTicks);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePlayMessage() {
|
||||
const itemId = getItemId();
|
||||
|
||||
if (!itemId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jellyfinItemDetails = await jellyfinClientManager
|
||||
.getJellyfinClient()
|
||||
.getItem(
|
||||
jellyfinClientManager.getJellyfinClient().getCurrentUserId(),
|
||||
getItemId()
|
||||
);
|
||||
|
||||
const primaryAlbumCover = await jellyfinClientManager
|
||||
.getJellyfinClient()
|
||||
.getImageUrl(jellyfinItemDetails.AlbumId || itemId, { type: "Primary" });
|
||||
|
||||
log.debug("Extracted primary Album cover url:", primaryAlbumCover);
|
||||
|
||||
try {
|
||||
interactivemsghandler.updateCurrentSongMessage(
|
||||
jellyfinItemDetails.Name,
|
||||
jellyfinItemDetails.Artists[0] || "VA",
|
||||
primaryAlbumCover,
|
||||
`${jellyfinClientManager
|
||||
.getJellyfinClient()
|
||||
.serverAddress()}/web/index.html#!/details?id=${
|
||||
jellyfinItemDetails.AlbumId
|
||||
}`,
|
||||
jellyfinItemDetails.RunTimeTicks,
|
||||
currentPlayingPlaylistIndex + 1,
|
||||
currentPlayingPlaylist.length
|
||||
);
|
||||
} catch (exception) {
|
||||
log.error("Exception during updating the current song message:", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Number} toSeek - where to seek in ticks
|
||||
*/
|
||||
function seek(toSeek = 0) {
|
||||
log.debug("Seeking to: ", toSeek);
|
||||
|
||||
if (!getAudioDispatcher()) {
|
||||
log.warn("Failed to seek because no song is playing.");
|
||||
}
|
||||
|
||||
// start playing the same track but with a specified time
|
||||
startPlaying(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
ticksToSeconds(toSeek),
|
||||
_disconnectOnFinish
|
||||
);
|
||||
|
||||
// report change about playback progress to Jellyfin
|
||||
jellyfinClientManager
|
||||
.getJellyfinClient()
|
||||
.reportPlaybackProgress(getProgressPayload());
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param {Array} trackItemIdsArray - array of itemIDs to be added
|
||||
*/
|
||||
function addTracks(trackItemIdsArray) {
|
||||
currentPlayingPlaylist = currentPlayingPlaylist.concat(trackItemIdsArray);
|
||||
log.debug(
|
||||
"Added tracks of",
|
||||
trackItemIdsArray.length,
|
||||
"to the current playlist"
|
||||
);
|
||||
}
|
||||
|
||||
function nextTrack() {
|
||||
log.debug("Going to the next track...");
|
||||
|
||||
if (!currentPlayingPlaylist) {
|
||||
log.warn(
|
||||
"Can't go to the next track, because there is currently nothing playing"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPlayingPlaylistIndex + 1 >= currentPlayingPlaylist.length) {
|
||||
log.warn(
|
||||
"Can't go to next track, because the current playing song is the last song."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
reportPlaybackStoppedAndStartPlaying(
|
||||
undefined,
|
||||
currentPlayingPlaylistIndex + 1,
|
||||
_disconnectOnFinish
|
||||
);
|
||||
}
|
||||
|
||||
function previousTrack() {
|
||||
log.debug("Going to the previous track...");
|
||||
|
||||
if (ticksToSeconds(getPostitionTicks()) > 10) {
|
||||
return;
|
||||
}
|
||||
|
||||
// don't go to the previous track when nothing is playing
|
||||
if (!currentPlayingPlaylist) {
|
||||
log.warn(
|
||||
"Can't go to the previous track, because there's currently nothing playing"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPlayingPlaylistIndex - 1 < 0) {
|
||||
log.warn(
|
||||
"Can't go to the previous track, because this is the first track in the playlist"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
reportPlaybackStoppedAndStartPlaying(
|
||||
undefined,
|
||||
currentPlayingPlaylistIndex - 1,
|
||||
_disconnectOnFinish
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object=} disconnectVoiceConnection - Optional The voice Connection do disconnect from
|
||||
*/
|
||||
function stop(disconnectVoiceConnection, itemId = getItemId()) {
|
||||
isPaused = true;
|
||||
if (interactivemsghandler.hasMessage()) {
|
||||
interactivemsghandler.destroy();
|
||||
}
|
||||
if (disconnectVoiceConnection) {
|
||||
disconnectVoiceConnection.disconnect();
|
||||
}
|
||||
log.debug(
|
||||
"stop playback and send following payload as reportPlaybackStopped to the server: ",
|
||||
getStopPayload()
|
||||
);
|
||||
jellyfinClientManager
|
||||
.getJellyfinClient()
|
||||
.reportPlaybackStopped(getStopPayload());
|
||||
if (getAudioDispatcher()) {
|
||||
try {
|
||||
getAudioDispatcher().destroy();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
setAudioDispatcher(undefined);
|
||||
}
|
||||
|
||||
function pause() {
|
||||
log.debug("Pausing the current track...");
|
||||
isPaused = true;
|
||||
|
||||
// report to Jellyfin that the client has paused the track
|
||||
jellyfinClientManager
|
||||
.getJellyfinClient()
|
||||
.reportPlaybackProgress(getProgressPayload());
|
||||
|
||||
// pause the track in the audio dispatcher
|
||||
getAudioDispatcher().pause(true);
|
||||
}
|
||||
|
||||
function resume() {
|
||||
log.debug("Resuming playback of the current track...");
|
||||
|
||||
isPaused = false;
|
||||
|
||||
// report to Jellyfin that the client has resumed playback
|
||||
jellyfinClientManager
|
||||
.getJellyfinClient()
|
||||
.reportPlaybackProgress(getProgressPayload());
|
||||
|
||||
// resume playback in the audio dispatcher
|
||||
getAudioDispatcher().resume();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses the playback of the current track is playing or
|
||||
* resumes the placback if the current track is paused
|
||||
*/
|
||||
function playPause() {
|
||||
const audioDispatcher = getAudioDispatcher();
|
||||
|
||||
if (!audioDispatcher) {
|
||||
log.warn(
|
||||
"Can't toggle the playback of the current song because there is nothing playing right now"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (audioDispatcher.paused) {
|
||||
log.debug("Resuming playback because the current track is paused...");
|
||||
resume();
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("Pausing the playback because the current track is playing...");
|
||||
pause();
|
||||
}
|
||||
|
||||
function getPostitionTicks() {
|
||||
// this is very sketchy but i dont know how else to do it
|
||||
return (
|
||||
(_seek + getAudioDispatcher().streamTime - getAudioDispatcher().pausedTime) *
|
||||
10000
|
||||
);
|
||||
}
|
||||
|
||||
function getPlayMethod() {
|
||||
// TODO figure out how to figure this out
|
||||
return "DirectPlay";
|
||||
}
|
||||
|
||||
function getRepeatMode() {
|
||||
if (isRepeat) {
|
||||
return "RepeatOne";
|
||||
}
|
||||
|
||||
return "RepeatNone";
|
||||
}
|
||||
|
||||
function getPlaylistItemId() {
|
||||
return getItemId();
|
||||
}
|
||||
|
||||
function getPlaySessionId() {
|
||||
// TODO: generate a unique identifier for identification at Jellyfin. This may cause conflicts when running multiple bots on the same Jellyfin server.
|
||||
return "ae2436edc6b91b11d72aeaa67f84e0ea";
|
||||
}
|
||||
|
||||
function getNowPLayingQueue() {
|
||||
return [
|
||||
{
|
||||
Id: getItemId(),
|
||||
// as I curently dont support Playlists
|
||||
PlaylistItemId: getPlaylistItemId()
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function getCanSeek() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function getIsMuted() {
|
||||
return false;
|
||||
}
|
||||
|
||||
function getVolumeLevel() {
|
||||
return 100;
|
||||
}
|
||||
|
||||
function getItemId() {
|
||||
if (typeof currentPlayingPlaylist !== "undefined") {
|
||||
return currentPlayingPlaylist[currentPlayingPlaylistIndex];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getIsPaused() {
|
||||
// AudioDispacker Paused is to slow
|
||||
|
||||
if (isPaused === undefined) {
|
||||
isPaused = false;
|
||||
}
|
||||
|
||||
return isPaused;
|
||||
}
|
||||
|
||||
function setIsRepeat(arg) {
|
||||
if (arg === undefined) {
|
||||
if (!(isRepeat === undefined)) {
|
||||
isRepeat = !isRepeat;
|
||||
}
|
||||
}
|
||||
isRepeat = arg;
|
||||
}
|
||||
|
||||
function getProgressPayload() {
|
||||
const payload = {
|
||||
CanSeek: getCanSeek(),
|
||||
IsMuted: getIsMuted(),
|
||||
IsPaused: getIsPaused(),
|
||||
ItemId: getItemId(),
|
||||
MediaSourceId: getItemId(),
|
||||
NowPlayingQueue: getNowPLayingQueue(),
|
||||
PlayMethod: getPlayMethod(),
|
||||
PlaySessionId: getPlaySessionId(),
|
||||
PlaylistItemId: getPlaylistItemId(),
|
||||
PositionTicks: getPostitionTicks(),
|
||||
RepeatMode: getRepeatMode(),
|
||||
VolumeLevel: getVolumeLevel(),
|
||||
EventName: "pauseplayupdate"
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
|
||||
function getStopPayload() {
|
||||
return {
|
||||
userId: jellyfinClientManager.getJellyfinClient().getCurrentUserId(),
|
||||
itemId: getItemId(),
|
||||
sessionID: getPlaySessionId(),
|
||||
playSessionId: getPlaySessionId(),
|
||||
positionTicks: getPostitionTicks()
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startPlaying,
|
||||
stop,
|
||||
playPause,
|
||||
resume,
|
||||
pause,
|
||||
seek,
|
||||
setIsRepeat,
|
||||
nextTrack,
|
||||
previousTrack,
|
||||
addTracks,
|
||||
getPostitionTicks,
|
||||
spawnPlayMessage
|
||||
};
|
3
src/types/env.ts
Normal file
3
src/types/env.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export interface EnvironmentVariablesType {
|
||||
DISCORD_CLIENT_TOKEN: string;
|
||||
}
|
55
src/util.js
55
src/util.js
@ -1,55 +0,0 @@
|
||||
function checkJellyfinItemIDRegex(strgintomatch) {
|
||||
const regexresult = strgintomatch.match(/([0-9]|[a-f]){32}/);
|
||||
if (regexresult) {
|
||||
return [regexresult[0]];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function ticksToSeconds(ticks) {
|
||||
return ticks / 10000000;
|
||||
}
|
||||
|
||||
function hmsToSeconds(str) {
|
||||
var p = str.split(":");
|
||||
var s = 0;
|
||||
var m = 1;
|
||||
|
||||
while (p.length > 0) {
|
||||
s += m * parseInt(p.pop(), 10);
|
||||
m *= 60;
|
||||
}
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
function secondsToHms(totalSeconds) {
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
totalSeconds %= 3600;
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
let seconds = Math.floor(totalSeconds % 60);
|
||||
seconds = seconds < 10 && seconds > 0 ? `0${seconds}` : `${seconds}`;
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes}:${seconds}`;
|
||||
} else {
|
||||
return `${minutes}:${seconds}`;
|
||||
}
|
||||
}
|
||||
|
||||
function getDiscordEmbedError(e) {
|
||||
const Discord = require("discord.js");
|
||||
return new Discord.MessageEmbed()
|
||||
.setColor(0xff0000)
|
||||
.setTitle("Error!")
|
||||
.setTimestamp()
|
||||
.setDescription("<:x:757935515445231651> " + e);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkJellyfinItemIDRegex,
|
||||
ticksToSeconds,
|
||||
hmsToSeconds,
|
||||
getDiscordEmbedError,
|
||||
secondsToHms,
|
||||
};
|
6
src/utils/constants.ts
Normal file
6
src/utils/constants.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export const Constants = {
|
||||
Metadata: {
|
||||
Version: "0.0.1",
|
||||
ApplicationName: "Discord Jellyfin Music Bot"
|
||||
}
|
||||
}
|
@ -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
24
test/app.e2e-spec.ts
Normal 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
9
test/jest-e2e.json
Normal 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
4
tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "es2017",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user