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
|
||||
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
|
||||
title: ''
|
||||
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
|
||||
title: ''
|
||||
labels: ''
|
||||
|
44
.gitignore
vendored
44
.gitignore
vendored
@ -1,4 +1,40 @@
|
||||
node_modules
|
||||
package-lock.json
|
||||
config.json
|
||||
.prettierrc
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
.yarn
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
.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">
|
||||
<img src="https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/jellyfin.png" alt="Jellyfin Logo" width="80" height="80">
|
||||
<h1 align="center">Jellyfin Discord Music Bot</h1>
|
||||
<div align="center">
|
||||
<span>A fork of the <a href="https://github.com/KGT1/jellyfin-discord-music-bot">original project</a> with improved readability and stability, compatible with Jellyfin 10.8.x</span>
|
||||
</div>
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://github.com/walkxcode/dashboard-icons/blob/main/png/jellyfin.png?raw=true" width="200" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
|
||||
# ✨ Features
|
||||
- Simple Discord Bot that hooks into the [Jellyfin](http://github.com/jellyfin/jellyfin) API of your instance
|
||||
- Request, pause and play songs directly from your Discord Server
|
||||
- Interactive Media control message to control playback
|
||||
<br/>
|
||||
<h1 align="center">Jellyfin Discord Bot</h1>
|
||||
|
||||
# 🦾 About this fork
|
||||
The original version is decent for Jellyfin 10.6.x and before. After the breaking changes of 10.7 and 10.8, users were unable to stream music from their Jellyfin.
|
||||
For this reason, I made this fork to address those changes to the API and improve the bot with my own ideas / features. Please check out the original project by [KGT1](https://github.com/KGT1).
|
||||
<p align="center">A simple <a href="https://discord.com" target="_blank">Discord</a> bot that enables you to broadcast<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>
|
||||
|
||||
I will gradually update documentation & code of the bot. Please wait patiently.
|
||||
|
||||
<br/><br/><br/><br/><br/><br/><hr/><br/>
|
||||
|
||||
## 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
|
||||
```
|
||||
<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>
|
||||
</p>
|
||||
|
||||
|
||||
### How to build
|
||||
```
|
||||
git clone https://github.com/kgt1/jellyfin-discord-music-bot.git
|
||||
cd jellyfin-discord-music-bot
|
||||
docker build -t YOUR_IMAGE_NAME .
|
||||
```
|
||||
<br/>
|
||||
<hr/>
|
||||
<br/>
|
||||
|
||||
|
||||
## ✨ 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",
|
||||
"version": "0.0.1",
|
||||
"description": "Jellyfin Discord Music Bot is a Discord Bot for the Jellyfin Media Server!",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"start": "node src/index.js",
|
||||
"postinstall": "npx patch-package",
|
||||
"lint": "npx eslint src/ & npx eslint parseENV.js"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/KGT1/jellyfin-discord-music-bot.git"
|
||||
},
|
||||
"keywords": [
|
||||
"Jellyfin",
|
||||
"Discord",
|
||||
"Discord-Bot"
|
||||
],
|
||||
"author": "KGT1",
|
||||
"description": "",
|
||||
"author": "manuel-rw",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/KGT1/jellyfin-discord-music-bot/issues"
|
||||
"scripts": {
|
||||
"prebuild": "rimraf dist",
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"homepage": "https://github.com/KGT1/jellyfin-discord-music-bot#readme",
|
||||
"dependencies": {
|
||||
"@discordjs/opus": "^0.3.2",
|
||||
"chalk": "^4.1.0",
|
||||
"discord.js": "^12.3.1",
|
||||
"jellyfin-apiclient": "1.7.0",
|
||||
"loglevel": "^1.7.1",
|
||||
"loglevel-plugin-prefix": "^0.8.4",
|
||||
"node-fetch": "^2.6.0",
|
||||
"nodejs": "0.0.0",
|
||||
"window": "^4.2.7",
|
||||
"ws": "^7.3.1"
|
||||
"@discord-nestjs/common": "^4.0.8",
|
||||
"@discord-nestjs/core": "^4.3.1",
|
||||
"@discordjs/opus": "^0.9.0",
|
||||
"@discordjs/voice": "^0.14.0",
|
||||
"@jellyfin/sdk": "^0.7.0",
|
||||
"@nestjs/common": "^9.0.0",
|
||||
"@nestjs/config": "^2.2.0",
|
||||
"@nestjs/core": "^9.0.0",
|
||||
"@nestjs/event-emitter": "^1.3.1",
|
||||
"@nestjs/platform-express": "^9.0.0",
|
||||
"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": {
|
||||
"patch-package": "^6.4.7",
|
||||
"eslint": "^7.9.0",
|
||||
"eslint-config-standard": "^14.1.1",
|
||||
"eslint-plugin-import": "^2.22.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-promise": "^4.2.1",
|
||||
"eslint-plugin-standard": "^4.0.1"
|
||||
"@nestjs/cli": "^9.0.0",
|
||||
"@nestjs/schematics": "^9.0.0",
|
||||
"@nestjs/testing": "^9.0.0",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/jest": "28.1.8",
|
||||
"@types/node": "^16.0.0",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
"eslint": "^8.0.1",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"jest": "28.1.3",
|
||||
"prettier": "^2.3.2",
|
||||
"source-map-support": "^0.5.20",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "28.0.8",
|
||||
"ts-loader": "^9.2.3",
|
||||
"ts-node": "^10.0.0",
|
||||
"tsconfig-paths": "4.1.0",
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
|
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