mirror of
https://github.com/informaticker/discord-jellyfin-bot.git
synced 2024-11-23 18:21:55 +01:00
✨ Migration to NestJS framework #6
This commit is contained in:
commit
acd8018207
@ -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
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',
|
||||||
|
},
|
||||||
|
};
|
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -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: ''
|
||||||
|
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -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
44
.gitignore
vendored
@ -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
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
|
185
README.md
185
README.md
@ -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)
|
@ -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"
|
|
||||||
}
|
|
BIN
images/discord-bot-profile-picture.png
Normal file
BIN
images/discord-bot-profile-picture.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 167 KiB |
BIN
images/discord-bot-profile-picture.xcf
Normal file
BIN
images/discord-bot-profile-picture.xcf
Normal file
Binary file not shown.
BIN
images/icons/alert-circle.png
Normal file
BIN
images/icons/alert-circle.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
BIN
images/icons/circle-check.png
Normal file
BIN
images/icons/circle-check.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
BIN
images/icons/jellyfin-icon-squared.png
Normal file
BIN
images/icons/jellyfin-icon-squared.png
Normal file
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
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
111
package.json
111
package.json
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
16
parseENV.js
16
parseENV.js
@ -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
@ -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
37
src/app.module.ts
Normal 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 {}
|
24
src/clients/discord/discord.config.service.ts
Normal file
24
src/clients/discord/discord.config.service.ts
Normal 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,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
67
src/clients/discord/discord.message.service.ts
Normal file
67
src/clients/discord/discord.message.service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
20
src/clients/discord/discord.module.ts
Normal file
20
src/clients/discord/discord.module.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
223
src/clients/discord/discord.voice.service.ts
Normal file
223
src/clients/discord/discord.voice.service.ts
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
33
src/clients/jellyfin/jellyfin.module.ts
Normal file
33
src/clients/jellyfin/jellyfin.module.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
105
src/clients/jellyfin/jellyfin.search.service.ts
Normal file
105
src/clients/jellyfin/jellyfin.search.service.ts
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
88
src/clients/jellyfin/jellyfin.service.ts
Normal file
88
src/clients/jellyfin/jellyfin.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
35
src/clients/jellyfin/jellyfin.stream.builder.service.ts
Normal file
35
src/clients/jellyfin/jellyfin.stream.builder.service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
23
src/clients/jellyfin/jellyfin.websocket.service.ts
Normal file
23
src/clients/jellyfin/jellyfin.websocket.service.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
40
src/commands/command.module.ts
Normal file
40
src/commands/command.module.ts
Normal 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 {}
|
35
src/commands/disconnect.command.ts
Normal file
35
src/commands/disconnect.command.ts
Normal 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',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
44
src/commands/help.command.ts
Normal file
44
src/commands/help.command.ts
Normal 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'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,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
40
src/commands/next.command.ts
Normal file
40
src/commands/next.command.ts
Normal 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',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
32
src/commands/pause.command.ts
Normal file
32
src/commands/pause.command.ts
Normal 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',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
248
src/commands/play.comands.ts
Normal file
248
src/commands/play.comands.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
78
src/commands/playlist.command.ts
Normal file
78
src/commands/playlist.command.ts
Normal 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}. `;
|
||||||
|
}
|
||||||
|
}
|
40
src/commands/previous.command.ts
Normal file
40
src/commands/previous.command.ts
Normal 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',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
94
src/commands/status.command.ts
Normal file
94
src/commands/status.command.ts
Normal 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,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
35
src/commands/stop.command.ts
Normal file
35
src/commands/stop.command.ts
Normal 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',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
43
src/commands/summon.command.ts
Normal file
43
src/commands/summon.command.ts
Normal 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',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
|
||||||
};
|
|
9
src/main.ts
Normal file
9
src/main.ts
Normal 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();
|
@ -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,
|
|
||||||
};
|
|
11
src/models/generic-try-handler.ts
Normal file
11
src/models/generic-try-handler.ts
Normal 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>;
|
248
src/models/jellyfinAudioItems.ts
Normal file
248
src/models/jellyfinAudioItems.ts
Normal 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,
|
||||||
|
)}`;
|
||||||
|
};
|
6
src/models/track-request.dto.ts
Normal file
6
src/models/track-request.dto.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { Param } from '@discord-nestjs/core';
|
||||||
|
|
||||||
|
export class TrackRequestDto {
|
||||||
|
@Param({ required: true, description: 'Track name to search' })
|
||||||
|
search: string;
|
||||||
|
}
|
10
src/playback/playback.module.ts
Normal file
10
src/playback/playback.module.ts
Normal 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 {}
|
131
src/playback/playback.service.ts
Normal file
131
src/playback/playback.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@ -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
4
src/types/colors.ts
Normal 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
3
src/types/env.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export interface EnvironmentVariablesType {
|
||||||
|
DISCORD_CLIENT_TOKEN: string;
|
||||||
|
}
|
9
src/types/playlist.ts
Normal file
9
src/types/playlist.ts
Normal 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
6
src/types/track.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export interface Track {
|
||||||
|
jellyfinId: string;
|
||||||
|
name: string;
|
||||||
|
durationInMilliseconds: number;
|
||||||
|
streamUrl: 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,
|
|
||||||
};
|
|
22
src/utils/constants.ts
Normal file
22
src/utils/constants.ts
Normal 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
9
src/utils/stringUtils.ts
Normal 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
11
src/utils/timeUtils.ts
Normal 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;
|
||||||
|
};
|
@ -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"]
|
||||||
|
}
|
22
tsconfig.json
Normal file
22
tsconfig.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user