mirror of
https://github.com/informaticker/discord-jellyfin-bot.git
synced 2024-11-24 18:41:57 +01:00
🔀 Merge branch 'master' of https://github.com/manuel-rw/jellyfin-discord-music-bot
This commit is contained in:
commit
24cfcb9369
14
.deepsource.toml
Normal file
14
.deepsource.toml
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
version = 1
|
||||||
|
|
||||||
|
[[analyzers]]
|
||||||
|
name = "javascript"
|
||||||
|
|
||||||
|
[analyzers.meta]
|
||||||
|
plugins = ["react"]
|
||||||
|
|
||||||
|
[[analyzers]]
|
||||||
|
name = "docker"
|
||||||
|
|
||||||
|
[[analyzers]]
|
||||||
|
name = "test-coverage"
|
||||||
|
enabled = true
|
84
.github/workflows/codeql.yml
vendored
84
.github/workflows/codeql.yml
vendored
@ -1,84 +0,0 @@
|
|||||||
# For most projects, this workflow file will not need changing; you simply need
|
|
||||||
# to commit it to your repository.
|
|
||||||
#
|
|
||||||
# You may wish to alter this file to override the set of languages analyzed,
|
|
||||||
# or to provide custom queries or build logic.
|
|
||||||
#
|
|
||||||
# ******** NOTE ********
|
|
||||||
# We have attempted to detect the languages in your repository. Please check
|
|
||||||
# the `language` matrix defined below to confirm you have the correct set of
|
|
||||||
# supported CodeQL languages.
|
|
||||||
#
|
|
||||||
name: "CodeQL"
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "master" ]
|
|
||||||
paths-ignore:
|
|
||||||
- '.github/**'
|
|
||||||
- 'images/'
|
|
||||||
- '*.md'
|
|
||||||
pull_request:
|
|
||||||
# The branches below must be a subset of the branches above
|
|
||||||
branches: [ "master" ]
|
|
||||||
paths-ignore:
|
|
||||||
- '.github/**'
|
|
||||||
- 'images/'
|
|
||||||
- '*.md'
|
|
||||||
schedule:
|
|
||||||
- cron: '31 2 * * 1'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
analyze:
|
|
||||||
name: Analyze
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
actions: read
|
|
||||||
contents: read
|
|
||||||
security-events: write
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
language: [ 'javascript' ]
|
|
||||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
|
||||||
# Use only 'java' to analyze code written in Java, Kotlin or both
|
|
||||||
# Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
|
|
||||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
|
||||||
- name: Initialize CodeQL
|
|
||||||
uses: github/codeql-action/init@v2
|
|
||||||
with:
|
|
||||||
languages: ${{ matrix.language }}
|
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
|
||||||
# By default, queries listed here will override any specified in a config file.
|
|
||||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
|
||||||
|
|
||||||
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
|
||||||
# queries: security-extended,security-and-quality
|
|
||||||
|
|
||||||
|
|
||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
|
|
||||||
# If this step fails, then you should remove it and run the build manually (see below)
|
|
||||||
- name: Autobuild
|
|
||||||
uses: github/codeql-action/autobuild@v2
|
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
|
||||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
|
||||||
|
|
||||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
|
||||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
|
||||||
|
|
||||||
# - run: |
|
|
||||||
# echo "Run, Build Application using script"
|
|
||||||
# ./location_of_script_within_repo/buildscript.sh
|
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
|
||||||
uses: github/codeql-action/analyze@v2
|
|
||||||
with:
|
|
||||||
category: "/language:${{matrix.language}}"
|
|
58
.github/workflows/deepsource-tests.yml
vendored
Normal file
58
.github/workflows/deepsource-tests.yml
vendored
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
name: Deepsource report test coverage
|
||||||
|
# This workflow uses actions that are not certified by GitHub.
|
||||||
|
# They are provided by a third-party and are governed by
|
||||||
|
# separate terms of service, privacy policy, and support
|
||||||
|
# documentation.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev,master]
|
||||||
|
paths-ignore:
|
||||||
|
- '.github/**'
|
||||||
|
- 'images/'
|
||||||
|
- '*.md'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- dev
|
||||||
|
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Use docker.io for Docker Hub if empty
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
# github.repository as <account>/<repo>
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
report-test-coverage:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Setup
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
- name: Get yarn cache directory path
|
||||||
|
id: yarn-cache-dir-path
|
||||||
|
run: echo "::set-output name=dir::$(yarn config get cacheFolder)"
|
||||||
|
- uses: actions/cache@v3
|
||||||
|
id: yarn-cache
|
||||||
|
with:
|
||||||
|
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||||
|
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-yarn-
|
||||||
|
- run: yarn install --immutable
|
||||||
|
- run: yarn test:cov
|
||||||
|
- run: curl https://deepsource.io/cli | sh
|
||||||
|
- name: Report test-coverage to DeepSource
|
||||||
|
run: |
|
||||||
|
# Install the CLI
|
||||||
|
curl https://deepsource.io/cli | sh
|
||||||
|
|
||||||
|
# Send the report to DeepSource
|
||||||
|
./bin/deepsource report --analyzer test-coverage --key javascript --value-file ./coverage/lcov.info
|
||||||
|
env:
|
||||||
|
DEEPSOURCE_DSN: ${{ secrets.DEEPSOURCE_DSN }}
|
@ -18,6 +18,7 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<br/>
|
<br/>
|
||||||
<img src="https://github.com/manuel-rw/jellyfin-discord-music-bot/actions/workflows/docker.yml/badge.svg?branch=master" />
|
<img src="https://github.com/manuel-rw/jellyfin-discord-music-bot/actions/workflows/docker.yml/badge.svg?branch=master" />
|
||||||
|
<img src="https://deepsource.io/gh/manuel-rw/jellyfin-discord-music-bot.svg/?label=active+issues&show_trend=true&token=vhfm8cbHaoCyXTf7Gfs9FweR)](https://deepsource.io/gh/manuel-rw/jellyfin-discord-music-bot/?ref=repository-badge" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
52
package.json
52
package.json
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "jellyfin-discord-music-bot",
|
"name": "jellyfin-discord-music-bot",
|
||||||
"version": "0.0.5",
|
"version": "0.0.7",
|
||||||
"description": "",
|
"description": "A simple and leightweight Discord Bot, that integrates with your Jellyfin Media server and enables you to listen to your favourite music directly from discord.",
|
||||||
"author": "manuel-rw",
|
"author": "manuel-rw",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -21,51 +21,53 @@
|
|||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discord-nestjs/common": "^5.2.2",
|
"@discord-nestjs/common": "^5.2.3",
|
||||||
"@discord-nestjs/core": "^5.3.4",
|
"@discord-nestjs/core": "^5.3.5",
|
||||||
"@discordjs/opus": "^0.9.0",
|
"@discordjs/opus": "^0.9.0",
|
||||||
"@discordjs/voice": "^0.15.0",
|
"@discordjs/voice": "^0.16.0",
|
||||||
"@jellyfin/sdk": "^0.7.0",
|
"@jellyfin/sdk": "^0.8.1",
|
||||||
"@nestjs/common": "^9.0.0",
|
"@nestjs/common": "^9.4.0",
|
||||||
"@nestjs/config": "^2.2.0",
|
"@nestjs/config": "^2.2.0",
|
||||||
"@nestjs/core": "^9.0.0",
|
"@nestjs/core": "^9.4.0",
|
||||||
"@nestjs/event-emitter": "^1.3.1",
|
"@nestjs/event-emitter": "^1.3.1",
|
||||||
"@nestjs/platform-express": "^9.0.0",
|
"@nestjs/platform-express": "^9.3.12",
|
||||||
"@nestjs/schedule": "^2.1.0",
|
"@nestjs/schedule": "^2.1.0",
|
||||||
"@nestjs/serve-static": "^3.0.1",
|
"@nestjs/serve-static": "^3.0.1",
|
||||||
"@nestjs/terminus": "^9.1.4",
|
"@nestjs/terminus": "^9.2.2",
|
||||||
"date-fns": "^2.29.3",
|
"date-fns": "^2.29.3",
|
||||||
"discord.js": "^14.8.0",
|
"discord.js": "^14.9.0",
|
||||||
"joi": "^17.8.4",
|
"joi": "^17.9.1",
|
||||||
"libsodium-wrappers": "^0.7.10",
|
"libsodium-wrappers": "^0.7.10",
|
||||||
|
"opusscript": "^0.0.8",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rimraf": "^4.4.0",
|
"rimraf": "^5.0.0",
|
||||||
"rxjs": "^7.2.0",
|
"rxjs": "^7.2.0",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
"ws": "^8.13.0"
|
"ws": "^8.13.0",
|
||||||
|
"zod": "^3.21.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^9.2.0",
|
"@nestjs/cli": "^9.3.0",
|
||||||
"@nestjs/schematics": "^9.0.0",
|
"@nestjs/schematics": "^9.1.0",
|
||||||
"@nestjs/testing": "^9.3.9",
|
"@nestjs/testing": "^9.4.0",
|
||||||
"@types/cron": "^2.0.0",
|
"@types/cron": "^2.0.1",
|
||||||
"@types/express": "^4.17.13",
|
"@types/express": "^4.17.13",
|
||||||
"@types/jest": "28.1.8",
|
"@types/jest": "28.1.8",
|
||||||
"@types/node": "^18.15.0",
|
"@types/node": "^18.15.11",
|
||||||
"@types/supertest": "^2.0.11",
|
"@types/supertest": "^2.0.11",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.53.0",
|
"@typescript-eslint/eslint-plugin": "^5.57.0",
|
||||||
"@typescript-eslint/parser": "^5.55.0",
|
"@typescript-eslint/parser": "^5.57.0",
|
||||||
"eslint": "^8.35.0",
|
"eslint": "^8.38.0",
|
||||||
"eslint-config-prettier": "^8.6.0",
|
"eslint-config-prettier": "^8.8.0",
|
||||||
"eslint-plugin-prettier": "^4.0.0",
|
"eslint-plugin-prettier": "^4.0.0",
|
||||||
"jest": "28.1.3",
|
"jest": "28.1.3",
|
||||||
"prettier": "^2.8.4",
|
"prettier": "^2.8.7",
|
||||||
"source-map-support": "^0.5.20",
|
"source-map-support": "^0.5.20",
|
||||||
"supertest": "^6.1.3",
|
"supertest": "^6.1.3",
|
||||||
"ts-jest": "28.0.8",
|
"ts-jest": "28.0.8",
|
||||||
"ts-loader": "^9.2.3",
|
"ts-loader": "^9.2.3",
|
||||||
"ts-node": "^10.0.0",
|
"ts-node": "^10.0.0",
|
||||||
"tsconfig-paths": "4.1.2",
|
"tsconfig-paths": "4.2.0",
|
||||||
"typescript": "^4.7.4"
|
"typescript": "^4.7.4"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
|
@ -28,6 +28,7 @@ import { UpdatesModule } from './updates/updates.module';
|
|||||||
UPDATER_DISABLE_NOTIFICATIONS: Joi.boolean(),
|
UPDATER_DISABLE_NOTIFICATIONS: Joi.boolean(),
|
||||||
LOG_LEVEL: Joi.string()
|
LOG_LEVEL: Joi.string()
|
||||||
.valid('error', 'warn', 'log', 'debug', 'verbose')
|
.valid('error', 'warn', 'log', 'debug', 'verbose')
|
||||||
|
.insensitive()
|
||||||
.default('log'),
|
.default('log'),
|
||||||
PORT: Joi.number().min(1),
|
PORT: Joi.number().min(1),
|
||||||
}),
|
}),
|
||||||
|
@ -9,7 +9,7 @@ import { GatewayIntentBits } from 'discord.js';
|
|||||||
export class DiscordConfigService implements DiscordOptionsFactory {
|
export class DiscordConfigService implements DiscordOptionsFactory {
|
||||||
createDiscordOptions(): DiscordModuleOption {
|
createDiscordOptions(): DiscordModuleOption {
|
||||||
return {
|
return {
|
||||||
token: process.env.DISCORD_CLIENT_TOKEN,
|
token: process.env.DISCORD_CLIENT_TOKEN ?? '',
|
||||||
discordClientOptions: {
|
discordClientOptions: {
|
||||||
intents: [
|
intents: [
|
||||||
GatewayIntentBits.Guilds,
|
GatewayIntentBits.Guilds,
|
||||||
|
@ -9,28 +9,29 @@ import {
|
|||||||
joinVoiceChannel,
|
joinVoiceChannel,
|
||||||
NoSubscriberBehavior,
|
NoSubscriberBehavior,
|
||||||
VoiceConnection,
|
VoiceConnection,
|
||||||
VoiceConnectionStatus,
|
|
||||||
} from '@discordjs/voice';
|
} from '@discordjs/voice';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Logger } from '@nestjs/common/services';
|
import { Logger } from '@nestjs/common/services';
|
||||||
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { Interval } from '@nestjs/schedule';
|
||||||
|
|
||||||
import { GuildMember } from 'discord.js';
|
import { GuildMember } from 'discord.js';
|
||||||
|
|
||||||
|
import { GenericTryHandler } from '../../models/generic-try-handler';
|
||||||
|
import { Track } from '../../models/shared/Track';
|
||||||
|
import { PlaybackService } from '../../playback/playback.service';
|
||||||
import { JellyfinStreamBuilderService } from '../jellyfin/jellyfin.stream.builder.service';
|
import { JellyfinStreamBuilderService } from '../jellyfin/jellyfin.stream.builder.service';
|
||||||
import { JellyfinWebSocketService } from '../jellyfin/jellyfin.websocket.service';
|
import { JellyfinWebSocketService } from '../jellyfin/jellyfin.websocket.service';
|
||||||
import { GenericTryHandler } from '../../models/generic-try-handler';
|
|
||||||
import { PlaybackService } from '../../playback/playback.service';
|
|
||||||
import { Track } from '../../models/shared/Track';
|
|
||||||
|
|
||||||
import { DiscordMessageService } from './discord.message.service';
|
import { DiscordMessageService } from './discord.message.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DiscordVoiceService {
|
export class DiscordVoiceService {
|
||||||
private readonly logger = new Logger(DiscordVoiceService.name);
|
private readonly logger = new Logger(DiscordVoiceService.name);
|
||||||
private audioPlayer: AudioPlayer;
|
private audioPlayer: AudioPlayer | undefined;
|
||||||
private voiceConnection: VoiceConnection;
|
private voiceConnection: VoiceConnection | undefined;
|
||||||
|
private audioResource: AudioResource | undefined;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly discordMessageService: DiscordMessageService,
|
private readonly discordMessageService: DiscordMessageService,
|
||||||
@ -40,10 +41,13 @@ export class DiscordVoiceService {
|
|||||||
private readonly eventEmitter: EventEmitter2,
|
private readonly eventEmitter: EventEmitter2,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@OnEvent('internal.audio.announce')
|
@OnEvent('internal.audio.track.announce')
|
||||||
handleOnNewTrack(track: Track) {
|
handleOnNewTrack(track: Track) {
|
||||||
const resource = createAudioResource(
|
const resource = createAudioResource(
|
||||||
track.getStreamUrl(this.jellyfinStreamBuilder),
|
track.getStreamUrl(this.jellyfinStreamBuilder),
|
||||||
|
{
|
||||||
|
inlineVolume: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
this.playResource(resource);
|
this.playResource(resource);
|
||||||
}
|
}
|
||||||
@ -89,7 +93,7 @@ export class DiscordVoiceService {
|
|||||||
|
|
||||||
this.jellyfinWebSocketService.initializeAndConnect();
|
this.jellyfinWebSocketService.initializeAndConnect();
|
||||||
|
|
||||||
if (this.voiceConnection == undefined) {
|
if (this.voiceConnection === undefined) {
|
||||||
this.voiceConnection = getVoiceConnection(member.guild.id);
|
this.voiceConnection = getVoiceConnection(member.guild.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,14 +103,26 @@ export class DiscordVoiceService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
changeVolume(volume: number) {
|
||||||
|
if (!this.audioResource || !this.audioResource.volume) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to change audio volume, AudioResource or volume was undefined`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.audioResource.volume.setVolume(volume);
|
||||||
|
}
|
||||||
|
|
||||||
playResource(resource: AudioResource<unknown>) {
|
playResource(resource: AudioResource<unknown>) {
|
||||||
this.logger.debug(`Playing audio resource with volume ${resource.volume}`);
|
this.logger.debug(`Playing audio resource with volume ${resource.volume}`);
|
||||||
this.createAndReturnOrGetAudioPlayer().play(resource);
|
this.createAndReturnOrGetAudioPlayer().play(resource);
|
||||||
|
this.audioResource = resource;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pauses the current audio player
|
* Pauses the current audio player
|
||||||
*/
|
*/
|
||||||
|
@OnEvent('internal.voice.controls.pause')
|
||||||
pause() {
|
pause() {
|
||||||
this.createAndReturnOrGetAudioPlayer().pause();
|
this.createAndReturnOrGetAudioPlayer().pause();
|
||||||
this.eventEmitter.emit('playback.state.pause', true);
|
this.eventEmitter.emit('playback.state.pause', true);
|
||||||
@ -115,6 +131,7 @@ export class DiscordVoiceService {
|
|||||||
/**
|
/**
|
||||||
* Stops the audio player
|
* Stops the audio player
|
||||||
*/
|
*/
|
||||||
|
@OnEvent('internal.voice.controls.stop')
|
||||||
stop(force: boolean): boolean {
|
stop(force: boolean): boolean {
|
||||||
const stopped = this.createAndReturnOrGetAudioPlayer().stop(force);
|
const stopped = this.createAndReturnOrGetAudioPlayer().stop(force);
|
||||||
this.eventEmitter.emit('playback.state.stop');
|
this.eventEmitter.emit('playback.state.stop');
|
||||||
@ -152,6 +169,7 @@ export class DiscordVoiceService {
|
|||||||
* Checks if the current state is paused or not and toggles the states to the opposite.
|
* 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
|
* @returns The new paused state - true: paused, false: unpaused
|
||||||
*/
|
*/
|
||||||
|
@OnEvent('internal.voice.controls.togglePause')
|
||||||
togglePaused(): boolean {
|
togglePaused(): boolean {
|
||||||
if (this.isPaused()) {
|
if (this.isPaused()) {
|
||||||
this.unpause();
|
this.unpause();
|
||||||
@ -207,7 +225,7 @@ export class DiscordVoiceService {
|
|||||||
|
|
||||||
if (this.audioPlayer === undefined) {
|
if (this.audioPlayer === undefined) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Initialized new instance of AudioPlayer because it has not been defined yet`,
|
'Initialized new instance of AudioPlayer because it has not been defined yet',
|
||||||
);
|
);
|
||||||
this.audioPlayer = createAudioPlayer({
|
this.audioPlayer = createAudioPlayer({
|
||||||
debug: process.env.DEBUG?.toLowerCase() === 'true',
|
debug: process.env.DEBUG?.toLowerCase() === 'true',
|
||||||
@ -224,6 +242,20 @@ export class DiscordVoiceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private attachEventListenersToAudioPlayer() {
|
private attachEventListenersToAudioPlayer() {
|
||||||
|
if (!this.voiceConnection) {
|
||||||
|
this.logger.error(
|
||||||
|
`Unable to attach listener events, because the VoiceConnection was undefined`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.audioPlayer) {
|
||||||
|
this.logger.error(
|
||||||
|
`Unable to attach listener events, because the AudioPlayer was undefined`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.voiceConnection.on('debug', (message) => {
|
this.voiceConnection.on('debug', (message) => {
|
||||||
if (process.env.DEBUG?.toLowerCase() !== 'true') {
|
if (process.env.DEBUG?.toLowerCase() !== 'true') {
|
||||||
return;
|
return;
|
||||||
@ -241,6 +273,13 @@ export class DiscordVoiceService {
|
|||||||
this.logger.error(message);
|
this.logger.error(message);
|
||||||
});
|
});
|
||||||
this.audioPlayer.on('stateChange', (previousState) => {
|
this.audioPlayer.on('stateChange', (previousState) => {
|
||||||
|
if (!this.audioPlayer) {
|
||||||
|
this.logger.error(
|
||||||
|
`Unable to process state change from audio player, because the current audio player in the callback was undefined`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Audio player changed state from ${previousState.status} to ${this.audioPlayer.state.status}`,
|
`Audio player changed state from ${previousState.status} to ${this.audioPlayer.state.status}`,
|
||||||
);
|
);
|
||||||
@ -253,22 +292,60 @@ export class DiscordVoiceService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(`Audio player finished playing old resource`);
|
this.logger.debug('Audio player finished playing old resource');
|
||||||
|
|
||||||
const hasNextTrack = this.playbackService
|
const playlist = this.playbackService.getPlaylistOrDefault();
|
||||||
.getPlaylistOrDefault()
|
const finishedTrack = playlist.getActiveTrack();
|
||||||
.hasNextTrackInPlaylist();
|
|
||||||
|
if (finishedTrack) {
|
||||||
|
finishedTrack.playing = false;
|
||||||
|
this.eventEmitter.emit('internal.audio.track.finish', finishedTrack);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasNextTrack = playlist.hasNextTrackInPlaylist();
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Playlist has next track: ${hasNextTrack ? 'yes' : 'no'}`,
|
`Playlist has next track: ${hasNextTrack ? 'yes' : 'no'}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hasNextTrack) {
|
if (!hasNextTrack) {
|
||||||
this.logger.debug(`Reached the end of the playlist`);
|
this.logger.debug('Reached the end of the playlist');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.playbackService.getPlaylistOrDefault().setNextTrackAsActiveTrack();
|
this.playbackService.getPlaylistOrDefault().setNextTrackAsActiveTrack();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Interval(500)
|
||||||
|
private checkAudioResourcePlayback() {
|
||||||
|
if (!this.audioResource) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = this.audioResource.playbackDuration;
|
||||||
|
|
||||||
|
const playlist = this.playbackService.getPlaylistOrDefault();
|
||||||
|
|
||||||
|
if (!playlist) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to update ellapsed audio time because playlist was unexpectitly undefined`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTrack = playlist.getActiveTrack();
|
||||||
|
|
||||||
|
if (!activeTrack) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to update ellapsed audio time because active track was unexpectitly undefined`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeTrack.updatePlaybackProgress(progress);
|
||||||
|
this.logger.verbose(
|
||||||
|
`Reporting progress: ${progress} on track ${activeTrack.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,9 +10,10 @@ import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api';
|
|||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { Interval } from '@nestjs/schedule';
|
||||||
|
import { Track } from '../../models/shared/Track';
|
||||||
|
|
||||||
import { PlaybackService } from '../../playback/playback.service';
|
import { PlaybackService } from '../../playback/playback.service';
|
||||||
import { Track } from '../../types/track';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JellyinPlaystateService {
|
export class JellyinPlaystateService {
|
||||||
@ -46,12 +47,71 @@ export class JellyinPlaystateService {
|
|||||||
this.logger.debug('Reported playback capabilities sucessfully');
|
this.logger.debug('Reported playback capabilities sucessfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent('playback.newTrack')
|
@OnEvent('internal.audio.track.announce')
|
||||||
private async onPlaybackNewTrack(track: Track) {
|
private async onPlaybackNewTrack(track: Track) {
|
||||||
|
this.logger.debug(`Reporting playback start on track '${track.id}'`);
|
||||||
await this.playstateApi.reportPlaybackStart({
|
await this.playstateApi.reportPlaybackStart({
|
||||||
playbackStartInfo: {
|
playbackStartInfo: {
|
||||||
ItemId: track.jellyfinId,
|
ItemId: track.id,
|
||||||
|
PositionTicks: 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OnEvent('internal.audio.track.finish')
|
||||||
|
private async onPlaybackFinished(track: Track) {
|
||||||
|
if (!track) {
|
||||||
|
this.logger.error(
|
||||||
|
'Unable to report playback because finished track was undefined',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logger.debug(`Reporting playback finish on track '${track.id}'`);
|
||||||
|
await this.playstateApi.reportPlaybackStopped({
|
||||||
|
playbackStopInfo: {
|
||||||
|
ItemId: track.id,
|
||||||
|
PositionTicks: track.playbackProgress * 10000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent('playback.state.pause')
|
||||||
|
private async onPlaybackPause(paused: boolean) {
|
||||||
|
const track = this.playbackService.getPlaylistOrDefault().getActiveTrack();
|
||||||
|
|
||||||
|
if (!track) {
|
||||||
|
this.logger.error(
|
||||||
|
'Unable to report changed playstate to Jellyfin because no track was active',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.playstateApi.reportPlaybackProgress({
|
||||||
|
playbackProgressInfo: {
|
||||||
|
IsPaused: paused,
|
||||||
|
ItemId: track.id,
|
||||||
|
PositionTicks: track.playbackProgress * 10000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Interval(1000)
|
||||||
|
private async onPlaybackProgress() {
|
||||||
|
const track = this.playbackService.getPlaylistOrDefault().getActiveTrack();
|
||||||
|
|
||||||
|
if (!track) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.playstateApi.reportPlaybackProgress({
|
||||||
|
playbackProgressInfo: {
|
||||||
|
ItemId: track.id,
|
||||||
|
PositionTicks: track.playbackProgress * 10000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.verbose(
|
||||||
|
`Reported playback progress ${track.playbackProgress} to Jellyfin for item ${track.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BaseItemDto,
|
||||||
BaseItemKind,
|
BaseItemKind,
|
||||||
RemoteImageResult,
|
RemoteImageResult,
|
||||||
SearchHint as JellyfinSearchHint,
|
SearchHint as JellyfinSearchHint,
|
||||||
@ -37,7 +38,7 @@ export class JellyfinSearchService {
|
|||||||
|
|
||||||
if (includeItemTypes.length === 0) {
|
if (includeItemTypes.length === 0) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Included item types are empty. This may lead to unwanted results`,
|
"Included item types are empty. This may lead to unwanted results",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,9 +58,13 @@ export class JellyfinSearchService {
|
|||||||
|
|
||||||
const { SearchHints } = data;
|
const { SearchHints } = data;
|
||||||
|
|
||||||
return SearchHints.map((hint) => this.transformToSearchHint(hint)).filter(
|
if (!SearchHints) {
|
||||||
(x) => x !== null,
|
throw new Error('SearchHints were undefined');
|
||||||
);
|
}
|
||||||
|
|
||||||
|
return SearchHints.map((hint) =>
|
||||||
|
this.transformToSearchHintFromHint(hint),
|
||||||
|
).filter((x) => x !== null) as SearchHint[];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Failed to search on Jellyfin: ${err}`);
|
this.logger.error(`Failed to search on Jellyfin: ${err}`);
|
||||||
return [];
|
return [];
|
||||||
@ -82,8 +87,15 @@ export class JellyfinSearchService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!axiosResponse.data.Items) {
|
||||||
|
this.logger.error(
|
||||||
|
`Jellyfin search returned no items: ${axiosResponse.data}`,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return axiosResponse.data.Items.map((hint) =>
|
return axiosResponse.data.Items.map((hint) =>
|
||||||
SearchHint.constructFromHint(hint),
|
SearchHint.constructFromBaseItem(hint),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -104,15 +116,22 @@ export class JellyfinSearchService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return axiosResponse.data.SearchHints.map((hint) =>
|
if (!axiosResponse.data.SearchHints) {
|
||||||
SearchHint.constructFromHint(hint),
|
this.logger.error(
|
||||||
);
|
`Received an unexpected empty list but expected a list of tracks of the album`,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...axiosResponse.data.SearchHints]
|
||||||
|
.reverse()
|
||||||
|
.map((hint) => SearchHint.constructFromHint(hint));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getById(
|
async getById(
|
||||||
id: string,
|
id: string,
|
||||||
includeItemTypes: BaseItemKind[],
|
includeItemTypes: BaseItemKind[],
|
||||||
): Promise<SearchHint> | undefined {
|
): Promise<SearchHint | undefined> {
|
||||||
const api = this.jellyfinService.getApi();
|
const api = this.jellyfinService.getApi();
|
||||||
|
|
||||||
const searchApi = getItemsApi(api);
|
const searchApi = getItemsApi(api);
|
||||||
@ -122,12 +141,35 @@ export class JellyfinSearchService {
|
|||||||
includeItemTypes: includeItemTypes,
|
includeItemTypes: includeItemTypes,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.Items.length !== 1) {
|
if (!data.Items || data.Items.length !== 1) {
|
||||||
this.logger.warn(`Failed to retrieve item via id '${id}'`);
|
this.logger.warn(`Failed to retrieve item via id '${id}'`);
|
||||||
return null;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.transformToSearchHint(data.Items[0]);
|
return this.transformToSearchHintFromBaseItemDto(data.Items[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllById(
|
||||||
|
ids: string[],
|
||||||
|
includeItemTypes: BaseItemKind[] = [BaseItemKind.Audio],
|
||||||
|
): Promise<SearchHint[]> {
|
||||||
|
const api = this.jellyfinService.getApi();
|
||||||
|
|
||||||
|
const searchApi = getItemsApi(api);
|
||||||
|
const { data } = await searchApi.getItems({
|
||||||
|
ids: ids,
|
||||||
|
userId: this.jellyfinService.getUserId(),
|
||||||
|
includeItemTypes: includeItemTypes,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data.Items || data.Items.length !== 1) {
|
||||||
|
this.logger.warn(`Failed to retrieve item via id '${ids}'`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.Items.map((item) =>
|
||||||
|
this.transformToSearchHintFromBaseItemDto(item),
|
||||||
|
).filter((searchHint) => searchHint !== undefined) as SearchHint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRemoteImageById(id: string, limit = 20): Promise<RemoteImageResult> {
|
async getRemoteImageById(id: string, limit = 20): Promise<RemoteImageResult> {
|
||||||
@ -170,7 +212,38 @@ export class JellyfinSearchService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private transformToSearchHint(jellyifnHint: JellyfinSearchHint) {
|
async getRandomTracks(limit: number) {
|
||||||
|
const api = this.jellyfinService.getApi();
|
||||||
|
const searchApi = getItemsApi(api);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await searchApi.getItems({
|
||||||
|
includeItemTypes: [BaseItemKind.Audio],
|
||||||
|
limit: limit,
|
||||||
|
sortBy: ['random'],
|
||||||
|
userId: this.jellyfinService.getUserId(),
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.data.Items) {
|
||||||
|
this.logger.error(
|
||||||
|
`Received empty list of items but expected a random list of tracks`,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data.Items.map((item) => {
|
||||||
|
return SearchHint.constructFromBaseItem(item);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Unable to retrieve random items from Jellyfin: ${err}`,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private transformToSearchHintFromHint(jellyifnHint: JellyfinSearchHint) {
|
||||||
switch (jellyifnHint.Type) {
|
switch (jellyifnHint.Type) {
|
||||||
case BaseItemKind[BaseItemKind.Audio]:
|
case BaseItemKind[BaseItemKind.Audio]:
|
||||||
return SearchHint.constructFromHint(jellyifnHint);
|
return SearchHint.constructFromHint(jellyifnHint);
|
||||||
@ -182,7 +255,23 @@ export class JellyfinSearchService {
|
|||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Received unexpected item type from Jellyfin search: ${jellyifnHint.Type}`,
|
`Received unexpected item type from Jellyfin search: ${jellyifnHint.Type}`,
|
||||||
);
|
);
|
||||||
return null;
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private transformToSearchHintFromBaseItemDto(baseItemDto: BaseItemDto) {
|
||||||
|
switch (baseItemDto.Type) {
|
||||||
|
case BaseItemKind[BaseItemKind.Audio]:
|
||||||
|
return SearchHint.constructFromBaseItem(baseItemDto);
|
||||||
|
case BaseItemKind[BaseItemKind.MusicAlbum]:
|
||||||
|
return AlbumSearchHint.constructFromBaseItem(baseItemDto);
|
||||||
|
case BaseItemKind[BaseItemKind.Playlist]:
|
||||||
|
return PlaylistSearchHint.constructFromBaseItem(baseItemDto);
|
||||||
|
default:
|
||||||
|
this.logger.warn(
|
||||||
|
`Received unexpected item type from Jellyfin search: ${baseItemDto.Type}`,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -33,18 +33,20 @@ export class JellyfinService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.api = this.jellyfin.createApi(process.env.JELLYFIN_SERVER_ADDRESS);
|
this.api = this.jellyfin.createApi(
|
||||||
|
process.env.JELLYFIN_SERVER_ADDRESS ?? '',
|
||||||
|
);
|
||||||
this.logger.debug('Created Jellyfin Client and Api');
|
this.logger.debug('Created Jellyfin Client and Api');
|
||||||
}
|
}
|
||||||
|
|
||||||
authenticate() {
|
authenticate() {
|
||||||
this.api
|
this.api
|
||||||
.authenticateUserByName(
|
.authenticateUserByName(
|
||||||
process.env.JELLYFIN_AUTHENTICATION_USERNAME,
|
process.env.JELLYFIN_AUTHENTICATION_USERNAME ?? '',
|
||||||
process.env.JELLYFIN_AUTHENTICATION_PASSWORD,
|
process.env.JELLYFIN_AUTHENTICATION_PASSWORD,
|
||||||
)
|
)
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
if (response.data.SessionInfo === undefined) {
|
if (response.data.SessionInfo?.UserId === undefined) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to authenticate with response code ${response.status}: '${response.data}'`,
|
`Failed to authenticate with response code ${response.status}: '${response.data}'`,
|
||||||
);
|
);
|
||||||
|
@ -4,8 +4,9 @@ import {
|
|||||||
} from '@jellyfin/sdk/lib/generated-client/models';
|
} from '@jellyfin/sdk/lib/generated-client/models';
|
||||||
|
|
||||||
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
||||||
import { Cron } from '@nestjs/schedule';
|
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { Cron } from '@nestjs/schedule';
|
||||||
|
import { convertToTracks } from 'src/utils/trackConverter';
|
||||||
|
|
||||||
import { WebSocket } from 'ws';
|
import { WebSocket } from 'ws';
|
||||||
|
|
||||||
@ -14,11 +15,9 @@ import {
|
|||||||
PlayNowCommand,
|
PlayNowCommand,
|
||||||
SessionApiSendPlaystateCommandRequest,
|
SessionApiSendPlaystateCommandRequest,
|
||||||
} from '../../types/websocket';
|
} from '../../types/websocket';
|
||||||
import { Track } from '../../models/shared/Track';
|
|
||||||
|
|
||||||
import { JellyfinSearchService } from './jellyfin.search.service';
|
import { JellyfinSearchService } from './jellyfin.search.service';
|
||||||
import { JellyfinService } from './jellyfin.service';
|
import { JellyfinService } from './jellyfin.service';
|
||||||
import { JellyfinStreamBuilderService } from './jellyfin.stream.builder.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JellyfinWebSocketService implements OnModuleDestroy {
|
export class JellyfinWebSocketService implements OnModuleDestroy {
|
||||||
@ -28,9 +27,8 @@ export class JellyfinWebSocketService implements OnModuleDestroy {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly jellyfinService: JellyfinService,
|
private readonly jellyfinService: JellyfinService,
|
||||||
private readonly jellyfinSearchService: JellyfinSearchService,
|
|
||||||
private readonly playbackService: PlaybackService,
|
private readonly playbackService: PlaybackService,
|
||||||
private readonly jellyfinStreamBuilderService: JellyfinStreamBuilderService,
|
private readonly jellyfinSearchService: JellyfinSearchService,
|
||||||
private readonly eventEmitter: EventEmitter2,
|
private readonly eventEmitter: EventEmitter2,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -103,8 +101,12 @@ export class JellyfinWebSocketService implements OnModuleDestroy {
|
|||||||
data.hasSelection = PlayNowCommand.prototype.hasSelection;
|
data.hasSelection = PlayNowCommand.prototype.hasSelection;
|
||||||
data.getSelection = PlayNowCommand.prototype.getSelection;
|
data.getSelection = PlayNowCommand.prototype.getSelection;
|
||||||
const ids = data.getSelection();
|
const ids = data.getSelection();
|
||||||
|
this.logger.log(
|
||||||
// TODO: Implement this again
|
`Processing ${ids.length} ids received via websocket and adding them to the queue`,
|
||||||
|
);
|
||||||
|
const searchHints = await this.jellyfinSearchService.getAllById(ids);
|
||||||
|
const tracks = convertToTracks(searchHints, this.jellyfinSearchService);
|
||||||
|
this.playbackService.getPlaylistOrDefault().enqueueTracks(tracks);
|
||||||
break;
|
break;
|
||||||
case SessionMessageType[SessionMessageType.Playstate]:
|
case SessionMessageType[SessionMessageType.Playstate]:
|
||||||
const sendPlaystateCommandRequest =
|
const sendPlaystateCommandRequest =
|
||||||
@ -124,13 +126,19 @@ export class JellyfinWebSocketService implements OnModuleDestroy {
|
|||||||
) {
|
) {
|
||||||
switch (request.Command) {
|
switch (request.Command) {
|
||||||
case PlaystateCommand.PlayPause:
|
case PlaystateCommand.PlayPause:
|
||||||
this.eventEmitter.emitAsync('playback.control.togglePause');
|
this.eventEmitter.emit('internal.voice.controls.togglePause');
|
||||||
break;
|
break;
|
||||||
case PlaystateCommand.Pause:
|
case PlaystateCommand.Pause:
|
||||||
this.eventEmitter.emitAsync('playback.control.pause');
|
this.eventEmitter.emit('internal.voice.controls.pause');
|
||||||
break;
|
break;
|
||||||
case PlaystateCommand.Stop:
|
case PlaystateCommand.Stop:
|
||||||
this.eventEmitter.emitAsync('playback.control.stop');
|
this.eventEmitter.emit('internal.voice.controls.stop');
|
||||||
|
break;
|
||||||
|
case PlaystateCommand.NextTrack:
|
||||||
|
this.eventEmitter.emit('internal.audio.track.next');
|
||||||
|
break;
|
||||||
|
case PlaystateCommand.PreviousTrack:
|
||||||
|
this.eventEmitter.emit('internal.audio.track.previous');
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
|
@ -15,6 +15,8 @@ import { StatusCommand } from './status.command';
|
|||||||
import { StopPlaybackCommand } from './stop.command';
|
import { StopPlaybackCommand } from './stop.command';
|
||||||
import { SummonCommand } from './summon.command';
|
import { SummonCommand } from './summon.command';
|
||||||
import { PlaylistInteractionCollector } from './playlist/playlist.interaction-collector';
|
import { PlaylistInteractionCollector } from './playlist/playlist.interaction-collector';
|
||||||
|
import { EnqueueRandomItemsCommand } from './random/random.command';
|
||||||
|
import { VolumeCommand } from './volume/volume.command';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -28,6 +30,7 @@ import { PlaylistInteractionCollector } from './playlist/playlist.interaction-co
|
|||||||
PlaylistInteractionCollector,
|
PlaylistInteractionCollector,
|
||||||
HelpCommand,
|
HelpCommand,
|
||||||
StatusCommand,
|
StatusCommand,
|
||||||
|
EnqueueRandomItemsCommand,
|
||||||
PlaylistCommand,
|
PlaylistCommand,
|
||||||
DisconnectCommand,
|
DisconnectCommand,
|
||||||
PausePlaybackCommand,
|
PausePlaybackCommand,
|
||||||
@ -36,6 +39,7 @@ import { PlaylistInteractionCollector } from './playlist/playlist.interaction-co
|
|||||||
SummonCommand,
|
SummonCommand,
|
||||||
PlayItemCommand,
|
PlayItemCommand,
|
||||||
PreviousTrackCommand,
|
PreviousTrackCommand,
|
||||||
|
VolumeCommand,
|
||||||
],
|
],
|
||||||
exports: [],
|
exports: [],
|
||||||
})
|
})
|
||||||
|
@ -27,7 +27,7 @@ import { DiscordVoiceService } from '../../clients/discord/discord.voice.service
|
|||||||
import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service';
|
import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service';
|
||||||
import { SearchHint } from '../../models/search/SearchHint';
|
import { SearchHint } from '../../models/search/SearchHint';
|
||||||
|
|
||||||
import { SearchType, PlayCommandParams } from './play.params.ts';
|
import { PlayCommandParams, SearchType } from './play.params.ts';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@Command({
|
@Command({
|
||||||
@ -48,12 +48,12 @@ export class PlayItemCommand {
|
|||||||
async handler(
|
async handler(
|
||||||
@InteractionEvent(SlashCommandPipe) dto: PlayCommandParams,
|
@InteractionEvent(SlashCommandPipe) dto: PlayCommandParams,
|
||||||
@IA() interaction: CommandInteraction,
|
@IA() interaction: CommandInteraction,
|
||||||
): Promise<InteractionReplyOptions | string> {
|
) {
|
||||||
await interaction.deferReply({ ephemeral: true });
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
const baseItems = PlayCommandParams.getBaseItemKinds(dto.type);
|
const baseItems = PlayCommandParams.getBaseItemKinds(dto.type);
|
||||||
|
|
||||||
let item: SearchHint;
|
let item: SearchHint | undefined;
|
||||||
if (dto.name.startsWith('native-')) {
|
if (dto.name.startsWith('native-')) {
|
||||||
item = await this.jellyfinSearchService.getById(
|
item = await this.jellyfinSearchService.getById(
|
||||||
dto.name.replace('native-', ''),
|
dto.name.replace('native-', ''),
|
||||||
@ -70,7 +70,8 @@ export class PlayItemCommand {
|
|||||||
embeds: [
|
embeds: [
|
||||||
this.discordMessageService.buildMessage({
|
this.discordMessageService.buildMessage({
|
||||||
title: 'No results found',
|
title: 'No results found',
|
||||||
description: `- Check for any misspellings\n- Grant me access to your desired libraries\n- Avoid special characters`,
|
description:
|
||||||
|
'- Check for any misspellings\n- Grant me access to your desired libraries\n- Avoid special characters',
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
ephemeral: true,
|
ephemeral: true,
|
||||||
@ -94,15 +95,19 @@ export class PlayItemCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tracks = await item.toTracks(this.jellyfinSearchService);
|
const tracks = await item.toTracks(this.jellyfinSearchService);
|
||||||
|
this.logger.debug(`Extracted ${tracks.length} tracks from the search item`);
|
||||||
const reducedDuration = tracks.reduce(
|
const reducedDuration = tracks.reduce(
|
||||||
(sum, item) => sum + item.duration,
|
(sum, item) => sum + item.duration,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
this.logger.debug(
|
||||||
|
`Adding ${tracks.length} tracks with a duration of ${reducedDuration} ticks`,
|
||||||
|
);
|
||||||
this.playbackService.getPlaylistOrDefault().enqueueTracks(tracks);
|
this.playbackService.getPlaylistOrDefault().enqueueTracks(tracks);
|
||||||
|
|
||||||
const remoteImage: RemoteImageInfo | undefined = tracks
|
const remoteImages = tracks.flatMap((track) => track.getRemoteImages());
|
||||||
.flatMap((x) => x.getRemoteImages())
|
const remoteImage: RemoteImageInfo | undefined =
|
||||||
.find((x) => true);
|
remoteImages.length > 0 ? remoteImages[0] : undefined;
|
||||||
|
|
||||||
await interaction.followUp({
|
await interaction.followUp({
|
||||||
embeds: [
|
embeds: [
|
||||||
@ -113,7 +118,7 @@ export class PlayItemCommand {
|
|||||||
reducedDuration,
|
reducedDuration,
|
||||||
)})`,
|
)})`,
|
||||||
mixin(embedBuilder) {
|
mixin(embedBuilder) {
|
||||||
if (!remoteImage) {
|
if (!remoteImage?.Url) {
|
||||||
return embedBuilder;
|
return embedBuilder;
|
||||||
}
|
}
|
||||||
return embedBuilder.setThumbnail(remoteImage.Url);
|
return embedBuilder.setThumbnail(remoteImage.Url);
|
||||||
@ -131,8 +136,9 @@ export class PlayItemCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const focusedAutoCompleteAction = interaction.options.getFocused(true);
|
const focusedAutoCompleteAction = interaction.options.getFocused(true);
|
||||||
const typeIndex: number | null = interaction.options.getInteger('type');
|
const typeIndex = interaction.options.getInteger('type');
|
||||||
const type = Object.values(SearchType)[typeIndex] as SearchType;
|
const type =
|
||||||
|
typeIndex !== null ? Object.values(SearchType)[typeIndex] : undefined;
|
||||||
const searchQuery = focusedAutoCompleteAction.value;
|
const searchQuery = focusedAutoCompleteAction.value;
|
||||||
|
|
||||||
if (!searchQuery || searchQuery.length < 1) {
|
if (!searchQuery || searchQuery.length < 1) {
|
||||||
@ -150,7 +156,7 @@ export class PlayItemCommand {
|
|||||||
const hints = await this.jellyfinSearchService.searchItem(
|
const hints = await this.jellyfinSearchService.searchItem(
|
||||||
searchQuery,
|
searchQuery,
|
||||||
20,
|
20,
|
||||||
PlayCommandParams.getBaseItemKinds(type),
|
PlayCommandParams.getBaseItemKinds(type as SearchType),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hints.length === 0) {
|
if (hints.length === 0) {
|
||||||
|
@ -22,16 +22,19 @@ import {
|
|||||||
InteractionUpdateOptions,
|
InteractionUpdateOptions,
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
|
|
||||||
import { PlaybackService } from '../../playback/playback.service';
|
|
||||||
import { chunkArray } from '../../utils/arrayUtils';
|
|
||||||
import { Constants } from '../../utils/constants';
|
|
||||||
import { formatMillisecondsAsHumanReadable } from '../../utils/timeUtils';
|
|
||||||
import { DiscordMessageService } from '../../clients/discord/discord.message.service';
|
import { DiscordMessageService } from '../../clients/discord/discord.message.service';
|
||||||
import { Track } from '../../models/shared/Track';
|
import { Track } from '../../models/shared/Track';
|
||||||
import { trimStringToFixedLength } from '../../utils/stringUtils/stringUtils';
|
import { PlaybackService } from '../../playback/playback.service';
|
||||||
|
import { chunkArray } from '../../utils/arrayUtils';
|
||||||
|
import { trimStringToFixedLength, zeroPad } from '../../utils/stringUtils/stringUtils';
|
||||||
|
|
||||||
|
import { Interval } from '@nestjs/schedule';
|
||||||
|
import { lightFormat } from 'date-fns';
|
||||||
import { PlaylistInteractionCollector } from './playlist.interaction-collector';
|
import { PlaylistInteractionCollector } from './playlist.interaction-collector';
|
||||||
import { PlaylistCommandParams } from './playlist.params';
|
import { PlaylistCommandParams } from './playlist.params';
|
||||||
|
import { PlaylistTempCommandData } from './playlist.types';
|
||||||
|
import { tr } from 'date-fns/locale';
|
||||||
|
import { takeCoverage } from 'v8';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@Command({
|
@Command({
|
||||||
@ -41,7 +44,7 @@ import { PlaylistCommandParams } from './playlist.params';
|
|||||||
@UseInterceptors(CollectorInterceptor)
|
@UseInterceptors(CollectorInterceptor)
|
||||||
@UseCollectors(PlaylistInteractionCollector)
|
@UseCollectors(PlaylistInteractionCollector)
|
||||||
export class PlaylistCommand {
|
export class PlaylistCommand {
|
||||||
public pageData: Map<string, number> = new Map();
|
public pageData: Map<string, PlaylistTempCommandData> = new Map();
|
||||||
private readonly logger = new Logger(PlaylistCommand.name);
|
private readonly logger = new Logger(PlaylistCommand.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -61,7 +64,10 @@ export class PlaylistCommand {
|
|||||||
this.getReplyForPage(page) as InteractionReplyOptions,
|
this.getReplyForPage(page) as InteractionReplyOptions,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.pageData.set(interaction.id, page);
|
this.pageData.set(interaction.id, {
|
||||||
|
page,
|
||||||
|
interaction,
|
||||||
|
});
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Added '${interaction.id}' as a message id for page storage`,
|
`Added '${interaction.id}' as a message id for page storage`,
|
||||||
);
|
);
|
||||||
@ -82,6 +88,36 @@ export class PlaylistCommand {
|
|||||||
return chunkArray(playlist.tracks, 10);
|
return chunkArray(playlist.tracks, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createInterval(interaction: CommandInteraction) {
|
||||||
|
return setInterval(async () => {
|
||||||
|
const tempData = this.pageData.get(interaction.id);
|
||||||
|
|
||||||
|
if (!tempData) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to update from interval, because temp data was not found`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply(this.getReplyForPage(tempData.page));
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Interval(2 * 1000)
|
||||||
|
private async updatePlaylists() {
|
||||||
|
if (this.pageData.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.verbose(
|
||||||
|
`Updating playlist for ${this.pageData.size} playlist datas`,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.pageData.forEach(async (value) => {
|
||||||
|
await value.interaction.editReply(this.getReplyForPage(value.page));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public getReplyForPage(
|
public getReplyForPage(
|
||||||
page: number,
|
page: number,
|
||||||
): InteractionReplyOptions | InteractionUpdateOptions {
|
): InteractionReplyOptions | InteractionUpdateOptions {
|
||||||
@ -176,26 +212,34 @@ export class PlaylistCommand {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const paddingNumber = playlist.getLength() >= 100 ? 3 : 2;
|
||||||
|
|
||||||
const content = chunk
|
const content = chunk
|
||||||
.map((track, index) => {
|
.map((track, index) => {
|
||||||
const isCurrent = track === playlist.getActiveTrack();
|
const isCurrent = track === playlist.getActiveTrack();
|
||||||
|
|
||||||
// use the offset for the page, add the current index and offset by one because the array index is used
|
let line = `\`\`${zeroPad(offset + index + 1, paddingNumber)}.\`\` `;
|
||||||
let point = `${offset + index + 1}. `;
|
line += this.getTrackName(track, isCurrent) + ' • ';
|
||||||
point += `**${trimStringToFixedLength(track.name, 30)}**`;
|
|
||||||
|
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
point += ' :loud_sound:';
|
line += lightFormat(track.getPlaybackProgress(), 'mm:ss') + ' / ';
|
||||||
}
|
}
|
||||||
|
line += lightFormat(track.getDuration(), 'mm:ss');
|
||||||
point += '\n';
|
if (isCurrent) {
|
||||||
point += Constants.Design.InvisibleSpace.repeat(2);
|
line += ' • (:play_pause:)';
|
||||||
point += formatMillisecondsAsHumanReadable(track.getDuration());
|
}
|
||||||
|
return line;
|
||||||
return point;
|
|
||||||
})
|
})
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
return new EmbedBuilder().setTitle('Your playlist').setDescription(content);
|
return new EmbedBuilder().setTitle('Your playlist').setDescription(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getTrackName(track: Track, active: boolean) {
|
||||||
|
const trimmedTitle = trimStringToFixedLength(track.name, 30);
|
||||||
|
if (active) {
|
||||||
|
return `**${trimmedTitle}**`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmedTitle;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,7 @@ import {
|
|||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
|
|
||||||
import { PlaylistCommand } from './playlist.command';
|
import { PlaylistCommand } from './playlist.command';
|
||||||
|
import { PlaylistTempCommandData } from './playlist.types';
|
||||||
|
|
||||||
@Injectable({ scope: Scope.REQUEST })
|
@Injectable({ scope: Scope.REQUEST })
|
||||||
@InteractionEventCollector({ time: 60 * 1000 })
|
@InteractionEventCollector({ time: 60 * 1000 })
|
||||||
@ -30,14 +31,17 @@ export class PlaylistInteractionCollector {
|
|||||||
|
|
||||||
@Filter()
|
@Filter()
|
||||||
filter(interaction: ButtonInteraction): boolean {
|
filter(interaction: ButtonInteraction): boolean {
|
||||||
return this.causeInteraction.id === interaction.message.interaction.id;
|
return (
|
||||||
|
interaction.message.interaction !== null &&
|
||||||
|
this.causeInteraction.id === interaction.message.interaction.id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@On('collect')
|
@On('collect')
|
||||||
async onCollect(interaction: ButtonInteraction): Promise<void> {
|
async onCollect(interaction: ButtonInteraction): Promise<void> {
|
||||||
const targetPage = this.getInteraction(interaction);
|
const targetPage = this.getInteraction(interaction);
|
||||||
this.logger.verbose(
|
this.logger.verbose(
|
||||||
`Extracted the target page ${targetPage} from the button interaction`,
|
`Extracted the target page '${targetPage?.page}' from the button interaction`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (targetPage === undefined) {
|
if (targetPage === undefined) {
|
||||||
@ -48,14 +52,16 @@ export class PlaylistInteractionCollector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Updating current page for interaction ${this.causeInteraction.id} to ${targetPage}`,
|
`Updating current page for interaction ${this.causeInteraction.id} to ${targetPage.page}`,
|
||||||
);
|
);
|
||||||
this.playlistCommand.pageData.set(this.causeInteraction.id, targetPage);
|
this.playlistCommand.pageData.set(this.causeInteraction.id, targetPage);
|
||||||
const reply = this.playlistCommand.getReplyForPage(targetPage);
|
const reply = this.playlistCommand.getReplyForPage(targetPage.page);
|
||||||
await interaction.update(reply as InteractionUpdateOptions);
|
await interaction.update(reply as InteractionUpdateOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getInteraction(interaction: ButtonInteraction): number | null {
|
private getInteraction(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
): PlaylistTempCommandData | undefined {
|
||||||
const current = this.playlistCommand.pageData.get(this.causeInteraction.id);
|
const current = this.playlistCommand.pageData.get(this.causeInteraction.id);
|
||||||
|
|
||||||
if (current === undefined) {
|
if (current === undefined) {
|
||||||
@ -75,12 +81,18 @@ export class PlaylistInteractionCollector {
|
|||||||
|
|
||||||
switch (interaction.customId) {
|
switch (interaction.customId) {
|
||||||
case 'playlist-controls-next':
|
case 'playlist-controls-next':
|
||||||
return current + 1;
|
return {
|
||||||
|
...current,
|
||||||
|
page: current.page + 1,
|
||||||
|
};
|
||||||
case 'playlist-controls-previous':
|
case 'playlist-controls-previous':
|
||||||
return current - 1;
|
return {
|
||||||
|
...current,
|
||||||
|
page: current.page - 1,
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Unable to map button interaction from collector to target page`,
|
'Unable to map button interaction from collector to target page',
|
||||||
);
|
);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
6
src/commands/playlist/playlist.types.ts
Normal file
6
src/commands/playlist/playlist.types.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { CommandInteraction } from 'discord.js';
|
||||||
|
|
||||||
|
export type PlaylistTempCommandData = {
|
||||||
|
page: number;
|
||||||
|
interaction: CommandInteraction;
|
||||||
|
};
|
76
src/commands/random/random.command.ts
Normal file
76
src/commands/random/random.command.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { SlashCommandPipe } from '@discord-nestjs/common';
|
||||||
|
import { Command, Handler, IA, InteractionEvent } from '@discord-nestjs/core';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
CommandInteraction,
|
||||||
|
GuildMember,
|
||||||
|
InteractionReplyOptions,
|
||||||
|
} from 'discord.js';
|
||||||
|
import { DiscordMessageService } from 'src/clients/discord/discord.message.service';
|
||||||
|
import { DiscordVoiceService } from 'src/clients/discord/discord.voice.service';
|
||||||
|
import { JellyfinSearchService } from 'src/clients/jellyfin/jellyfin.search.service';
|
||||||
|
import { SearchHint } from 'src/models/search/SearchHint';
|
||||||
|
import { PlaybackService } from 'src/playback/playback.service';
|
||||||
|
import { RandomCommandParams } from './random.params';
|
||||||
|
|
||||||
|
@Command({
|
||||||
|
name: 'random',
|
||||||
|
description: 'Enqueues a random selection of tracks to your playlist',
|
||||||
|
})
|
||||||
|
@Injectable()
|
||||||
|
export class EnqueueRandomItemsCommand {
|
||||||
|
constructor(
|
||||||
|
private readonly playbackService: PlaybackService,
|
||||||
|
private readonly discordVoiceService: DiscordVoiceService,
|
||||||
|
private readonly discordMessageService: DiscordMessageService,
|
||||||
|
private readonly jellyfinSearchService: JellyfinSearchService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Handler()
|
||||||
|
async handler(
|
||||||
|
@InteractionEvent(SlashCommandPipe) dto: RandomCommandParams,
|
||||||
|
@IA() interaction: CommandInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
const guildMember = interaction.member as GuildMember;
|
||||||
|
|
||||||
|
const tryResult =
|
||||||
|
this.discordVoiceService.tryJoinChannelAndEstablishVoiceConnection(
|
||||||
|
guildMember,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!tryResult.success) {
|
||||||
|
const replyOptions = tryResult.reply as InteractionReplyOptions;
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: replyOptions.embeds,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await this.jellyfinSearchService.getRandomTracks(dto.count);
|
||||||
|
const tracks = await this.getTracks(items);
|
||||||
|
|
||||||
|
this.playbackService.getPlaylistOrDefault().enqueueTracks(tracks);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [
|
||||||
|
this.discordMessageService.buildMessage({
|
||||||
|
title: `Added ${tracks.length} tracks to your playlist`,
|
||||||
|
description: 'Use ``/playlist`` to see them',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTracks(hints: SearchHint[]) {
|
||||||
|
const promises = await Promise.all(
|
||||||
|
hints.flatMap(async (item) => {
|
||||||
|
const tracks = await item.toTracks(this.jellyfinSearchService);
|
||||||
|
return tracks;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return promises.flatMap((x) => x);
|
||||||
|
}
|
||||||
|
}
|
12
src/commands/random/random.params.ts
Normal file
12
src/commands/random/random.params.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Param, ParamType } from '@discord-nestjs/core';
|
||||||
|
|
||||||
|
export class RandomCommandParams {
|
||||||
|
@Param({
|
||||||
|
required: false,
|
||||||
|
description: 'Count of items to search for',
|
||||||
|
type: ParamType.INTEGER,
|
||||||
|
minValue: 0,
|
||||||
|
maxValue: 10000,
|
||||||
|
})
|
||||||
|
count = 20;
|
||||||
|
}
|
@ -39,7 +39,7 @@ export class StatusCommand {
|
|||||||
const status = Status[this.client.ws.status];
|
const status = Status[this.client.ws.status];
|
||||||
|
|
||||||
const interval = intervalToDuration({
|
const interval = intervalToDuration({
|
||||||
start: this.client.uptime,
|
start: this.client.uptime ?? 0,
|
||||||
end: 0,
|
end: 0,
|
||||||
});
|
});
|
||||||
const formattedDuration = formatDuration(interval);
|
const formattedDuration = formatDuration(interval);
|
||||||
|
@ -30,8 +30,8 @@ export class StopPlaybackCommand {
|
|||||||
? 'In addition, your playlist has been cleared'
|
? 'In addition, your playlist has been cleared'
|
||||||
: 'There is no active track in the queue';
|
: 'There is no active track in the queue';
|
||||||
if (hasActiveTrack) {
|
if (hasActiveTrack) {
|
||||||
this.playbackService.getPlaylistOrDefault().clear();
|
|
||||||
this.discordVoiceService.stop(false);
|
this.discordVoiceService.stop(false);
|
||||||
|
// this.playbackService.getPlaylistOrDefault().clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
|
69
src/commands/volume/volume.command.ts
Normal file
69
src/commands/volume/volume.command.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { SlashCommandPipe } from '@discord-nestjs/common';
|
||||||
|
import { Command, Handler, IA, InteractionEvent } from '@discord-nestjs/core';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common/decorators';
|
||||||
|
|
||||||
|
import { CommandInteraction } from 'discord.js';
|
||||||
|
import { DiscordMessageService } from 'src/clients/discord/discord.message.service';
|
||||||
|
import { DiscordVoiceService } from 'src/clients/discord/discord.voice.service';
|
||||||
|
import { PlaybackService } from 'src/playback/playback.service';
|
||||||
|
import { sleep } from 'src/utils/timeUtils';
|
||||||
|
import { VolumeCommandParams } from './volume.params';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
@Command({
|
||||||
|
name: 'volume',
|
||||||
|
description: 'Change the volume',
|
||||||
|
})
|
||||||
|
export class VolumeCommand {
|
||||||
|
private readonly logger = new Logger(VolumeCommand.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly discordVoiceService: DiscordVoiceService,
|
||||||
|
private readonly discordMessageService: DiscordMessageService,
|
||||||
|
private readonly playbackService: PlaybackService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Handler()
|
||||||
|
async handler(
|
||||||
|
@InteractionEvent(SlashCommandPipe) dto: VolumeCommandParams,
|
||||||
|
@IA() interaction: CommandInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
if (!this.playbackService.getPlaylistOrDefault().hasActiveTrack()) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [
|
||||||
|
this.discordMessageService.buildMessage({
|
||||||
|
title: "Unable to change your volume",
|
||||||
|
description:
|
||||||
|
'The bot is not playing any music or is not straming to a channel',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const volume = dto.volume / 100;
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Calculated volume ${volume} from dto param ${dto.volume}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.discordVoiceService.changeVolume(volume);
|
||||||
|
|
||||||
|
// Discord takes some time to react. Confirmation message should appear after actual change
|
||||||
|
await sleep(1500);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [
|
||||||
|
this.discordMessageService.buildMessage({
|
||||||
|
title: `Sucessfully set volume to ${dto.volume.toFixed(0)}%`,
|
||||||
|
description:
|
||||||
|
'Updating may take a few seconds to take effect.\nPlease note that listening at a high volume for a long time may damage your hearing',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
12
src/commands/volume/volume.params.ts
Normal file
12
src/commands/volume/volume.params.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Param, ParamType } from '@discord-nestjs/core';
|
||||||
|
|
||||||
|
export class VolumeCommandParams {
|
||||||
|
@Param({
|
||||||
|
required: true,
|
||||||
|
description: 'The desired volume',
|
||||||
|
type: ParamType.INTEGER,
|
||||||
|
minValue: 0,
|
||||||
|
maxValue: 150,
|
||||||
|
})
|
||||||
|
volume: number;
|
||||||
|
}
|
@ -1,3 +1,4 @@
|
|||||||
|
import { InjectionToken } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
HealthCheckResult,
|
HealthCheckResult,
|
||||||
HealthCheckService,
|
HealthCheckService,
|
||||||
@ -42,10 +43,14 @@ describe('HealthController', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (token === HealthCheckService) {
|
if (token === HealthCheckService) {
|
||||||
return new HealthCheckService(new HealthCheckExecutor(), null, null);
|
return new HealthCheckService(
|
||||||
|
new HealthCheckExecutor(),
|
||||||
|
{ getErrorMessage: jest.fn() },
|
||||||
|
{ log: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return useDefaultMockerToken(token);
|
return useDefaultMockerToken(token as InjectionToken);
|
||||||
})
|
})
|
||||||
.compile();
|
.compile();
|
||||||
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { InjectionToken } from '@nestjs/common';
|
||||||
import { HealthIndicatorResult } from '@nestjs/terminus';
|
import { HealthIndicatorResult } from '@nestjs/terminus';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { JellyfinService } from '../../clients/jellyfin/jellyfin.service';
|
import { JellyfinService } from '../../clients/jellyfin/jellyfin.service';
|
||||||
@ -16,7 +17,7 @@ describe('JellyfinHealthIndicator', () => {
|
|||||||
if (token === JellyfinService) {
|
if (token === JellyfinService) {
|
||||||
return { isConnected: jest.fn() };
|
return { isConnected: jest.fn() };
|
||||||
}
|
}
|
||||||
return useDefaultMockerToken(token);
|
return useDefaultMockerToken(token as InjectionToken);
|
||||||
})
|
})
|
||||||
.compile();
|
.compile();
|
||||||
|
|
||||||
|
@ -4,6 +4,10 @@ import { NestFactory } from '@nestjs/core';
|
|||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
function getLoggingLevels(): LogLevel[] {
|
function getLoggingLevels(): LogLevel[] {
|
||||||
|
if (!process.env.LOG_LEVEL) {
|
||||||
|
return ['error', 'warn', 'log'];
|
||||||
|
}
|
||||||
|
|
||||||
switch (process.env.LOG_LEVEL.toLowerCase()) {
|
switch (process.env.LOG_LEVEL.toLowerCase()) {
|
||||||
case 'error':
|
case 'error':
|
||||||
return ['error'];
|
return ['error'];
|
||||||
|
@ -11,16 +11,28 @@ export class AlbumSearchHint extends SearchHint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static constructFromHint(hint: JellyfinSearchHint) {
|
static constructFromHint(hint: JellyfinSearchHint) {
|
||||||
|
if (hint.Id === undefined || !hint.Name || !hint.RunTimeTicks) {
|
||||||
|
throw new Error(
|
||||||
|
'Unable to construct playlist search hint, required properties were undefined',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return new AlbumSearchHint(hint.Id, hint.Name, hint.RunTimeTicks / 10000);
|
return new AlbumSearchHint(hint.Id, hint.Name, hint.RunTimeTicks / 10000);
|
||||||
}
|
}
|
||||||
|
|
||||||
override async toTracks(
|
override async toTracks(
|
||||||
searchService: JellyfinSearchService,
|
searchService: JellyfinSearchService,
|
||||||
): Promise<Track[]> {
|
): Promise<Track[]> {
|
||||||
|
const remoteImages = await searchService.getRemoteImageById(this.id);
|
||||||
const albumItems = await searchService.getAlbumItems(this.id);
|
const albumItems = await searchService.getAlbumItems(this.id);
|
||||||
const tracks = albumItems.map(async (x) =>
|
const tracks = await Promise.all(
|
||||||
(await x.toTracks(searchService)).find((x) => x !== null),
|
albumItems.map(async (x) =>
|
||||||
|
(await x.toTracks(searchService)).find((x) => x !== null),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return await Promise.all(tracks);
|
return tracks.map((track: Track): Track => {
|
||||||
|
track.remoteImages = remoteImages;
|
||||||
|
return track;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ import { Track } from '../shared/Track';
|
|||||||
import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service';
|
import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service';
|
||||||
|
|
||||||
import { SearchHint } from './SearchHint';
|
import { SearchHint } from './SearchHint';
|
||||||
|
import { convertToTracks } from 'src/utils/trackConverter';
|
||||||
|
|
||||||
export class PlaylistSearchHint extends SearchHint {
|
export class PlaylistSearchHint extends SearchHint {
|
||||||
override toString(): string {
|
override toString(): string {
|
||||||
@ -11,6 +12,12 @@ export class PlaylistSearchHint extends SearchHint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static constructFromHint(hint: JellyfinSearchHint) {
|
static constructFromHint(hint: JellyfinSearchHint) {
|
||||||
|
if (hint.Id === undefined || !hint.Name || !hint.RunTimeTicks) {
|
||||||
|
throw new Error(
|
||||||
|
'Unable to construct playlist search hint, required properties were undefined',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return new PlaylistSearchHint(
|
return new PlaylistSearchHint(
|
||||||
hint.Id,
|
hint.Id,
|
||||||
hint.Name,
|
hint.Name,
|
||||||
@ -22,9 +29,6 @@ export class PlaylistSearchHint extends SearchHint {
|
|||||||
searchService: JellyfinSearchService,
|
searchService: JellyfinSearchService,
|
||||||
): Promise<Track[]> {
|
): Promise<Track[]> {
|
||||||
const playlistItems = await searchService.getPlaylistitems(this.id);
|
const playlistItems = await searchService.getPlaylistitems(this.id);
|
||||||
const tracks = playlistItems.map(async (x) =>
|
return convertToTracks(playlistItems, searchService);
|
||||||
(await x.toTracks(searchService)).find((x) => x !== null),
|
|
||||||
);
|
|
||||||
return await Promise.all(tracks);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
import { SearchHint as JellyfinSearchHint } from '@jellyfin/sdk/lib/generated-client/models';
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
SearchHint as JellyfinSearchHint,
|
||||||
|
} from '@jellyfin/sdk/lib/generated-client/models';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { Track } from '../shared/Track';
|
|
||||||
import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service';
|
import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service';
|
||||||
|
import { Track } from '../shared/Track';
|
||||||
|
|
||||||
export class SearchHint {
|
export class SearchHint {
|
||||||
constructor(
|
constructor(
|
||||||
@ -15,10 +19,7 @@ export class SearchHint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async toTracks(searchService: JellyfinSearchService): Promise<Track[]> {
|
async toTracks(searchService: JellyfinSearchService): Promise<Track[]> {
|
||||||
const remoteImages = await searchService.getRemoteImageById(this.id);
|
return [new Track(this.id, this.name, this.runtimeInMilliseconds, {})];
|
||||||
return [
|
|
||||||
new Track(this.id, this.name, this.runtimeInMilliseconds, remoteImages),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getId(): string {
|
getId(): string {
|
||||||
@ -26,6 +27,39 @@ export class SearchHint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static constructFromHint(hint: JellyfinSearchHint) {
|
static constructFromHint(hint: JellyfinSearchHint) {
|
||||||
return new SearchHint(hint.Id, hint.Name, hint.RunTimeTicks / 10000);
|
const schema = z.object({
|
||||||
|
Id: z.string(),
|
||||||
|
Name: z.string(),
|
||||||
|
RunTimeTicks: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = schema.safeParse(hint);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(
|
||||||
|
`Unable to construct search hint, required properties were undefined: ${JSON.stringify(
|
||||||
|
hint,
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SearchHint(
|
||||||
|
result.data.Id,
|
||||||
|
result.data.Name,
|
||||||
|
result.data.RunTimeTicks / 10000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static constructFromBaseItem(baseItem: BaseItemDto) {
|
||||||
|
if (baseItem.Id === undefined || !baseItem.Name || !baseItem.RunTimeTicks) {
|
||||||
|
throw new Error(
|
||||||
|
'Unable to construct search hint from base item, required properties were undefined',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new SearchHint(
|
||||||
|
baseItem.Id,
|
||||||
|
baseItem.Name,
|
||||||
|
baseItem.RunTimeTicks / 10000,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||||
|
|
||||||
import { Track } from './Track';
|
import { Track } from './Track';
|
||||||
|
|
||||||
@ -24,7 +24,7 @@ export class Playlist {
|
|||||||
* @returns active track or undefined if there's none
|
* @returns active track or undefined if there's none
|
||||||
*/
|
*/
|
||||||
getActiveTrack(): Track | undefined {
|
getActiveTrack(): Track | undefined {
|
||||||
if (this.isActiveTrackOutOfSync()) {
|
if (this.isActiveTrackOutOfSync() || this.activeTrackIndex === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return this.tracks[this.activeTrackIndex];
|
return this.tracks[this.activeTrackIndex];
|
||||||
@ -49,7 +49,12 @@ export class Playlist {
|
|||||||
* @returns if the track has been changed successfully
|
* @returns if the track has been changed successfully
|
||||||
*/
|
*/
|
||||||
setNextTrackAsActiveTrack(): boolean {
|
setNextTrackAsActiveTrack(): boolean {
|
||||||
if (this.activeTrackIndex >= this.tracks.length) {
|
this.announceTrackFinishIfSet();
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.activeTrackIndex === undefined ||
|
||||||
|
this.activeTrackIndex >= this.tracks.length
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,7 +71,9 @@ export class Playlist {
|
|||||||
* @returns if the track has been changed successfully
|
* @returns if the track has been changed successfully
|
||||||
*/
|
*/
|
||||||
setPreviousTrackAsActiveTrack(): boolean {
|
setPreviousTrackAsActiveTrack(): boolean {
|
||||||
if (this.activeTrackIndex <= 0) {
|
this.announceTrackFinishIfSet();
|
||||||
|
|
||||||
|
if (this.activeTrackIndex === undefined || this.activeTrackIndex <= 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,12 +91,25 @@ export class Playlist {
|
|||||||
* @returns the new lendth of the tracks in the playlist
|
* @returns the new lendth of the tracks in the playlist
|
||||||
*/
|
*/
|
||||||
enqueueTracks(tracks: Track[]) {
|
enqueueTracks(tracks: Track[]) {
|
||||||
|
if (tracks.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousTrackLength = this.tracks.length;
|
||||||
|
|
||||||
this.eventEmitter.emit('controls.playlist.tracks.enqueued', {
|
this.eventEmitter.emit('controls.playlist.tracks.enqueued', {
|
||||||
count: tracks.length,
|
count: tracks.length,
|
||||||
activeTrack: this.activeTrackIndex,
|
activeTrack: this.activeTrackIndex,
|
||||||
});
|
});
|
||||||
const length = this.tracks.push(...tracks);
|
const length = this.tracks.push(...tracks);
|
||||||
|
|
||||||
|
// existing tracks are in the playlist, but none are playing. play the first track out of the new tracks
|
||||||
|
if (!this.hasAnyPlaying() && tracks.length > 0) {
|
||||||
|
this.activeTrackIndex = previousTrackLength;
|
||||||
|
this.announceTrackChange();
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
|
||||||
// emit a track change if there is no item
|
// emit a track change if there is no item
|
||||||
if (this.activeTrackIndex === undefined) {
|
if (this.activeTrackIndex === undefined) {
|
||||||
this.announceTrackChange();
|
this.announceTrackChange();
|
||||||
@ -103,7 +123,7 @@ export class Playlist {
|
|||||||
* @returns if there is a track next in the playlist
|
* @returns if there is a track next in the playlist
|
||||||
*/
|
*/
|
||||||
hasNextTrackInPlaylist() {
|
hasNextTrackInPlaylist() {
|
||||||
return this.activeTrackIndex + 1 < this.tracks.length;
|
return (this.activeTrackIndex ?? 0) + 1 < this.tracks.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -111,7 +131,7 @@ export class Playlist {
|
|||||||
* @returns if there is a previous track in the playlist
|
* @returns if there is a previous track in the playlist
|
||||||
*/
|
*/
|
||||||
hasPreviousTrackInPlaylist() {
|
hasPreviousTrackInPlaylist() {
|
||||||
return this.activeTrackIndex > 0;
|
return this.activeTrackIndex !== undefined && this.activeTrackIndex > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
@ -120,17 +140,39 @@ export class Playlist {
|
|||||||
this.activeTrackIndex = undefined;
|
this.activeTrackIndex = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private hasAnyPlaying() {
|
||||||
|
return this.tracks.some((track) => track.playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private announceTrackFinishIfSet() {
|
||||||
|
if (this.activeTrackIndex === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTrack = this.getActiveTrack();
|
||||||
|
this.eventEmitter.emit('internal.audio.track.finish', currentTrack);
|
||||||
|
}
|
||||||
|
|
||||||
private announceTrackChange() {
|
private announceTrackChange() {
|
||||||
if (!this.activeTrackIndex) {
|
if (!this.activeTrackIndex) {
|
||||||
this.activeTrackIndex = 0;
|
this.activeTrackIndex = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.eventEmitter.emit('internal.audio.announce', this.getActiveTrack());
|
const activeTrack = this.getActiveTrack();
|
||||||
|
|
||||||
|
if (!activeTrack) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeTrack.playing = true;
|
||||||
|
this.eventEmitter.emit('internal.audio.track.announce', activeTrack);
|
||||||
}
|
}
|
||||||
|
|
||||||
private isActiveTrackOutOfSync(): boolean {
|
private isActiveTrackOutOfSync(): boolean {
|
||||||
return (
|
return (
|
||||||
this.activeTrackIndex < 0 || this.activeTrackIndex >= this.tracks.length
|
this.activeTrackIndex === undefined ||
|
||||||
|
this.activeTrackIndex < 0 ||
|
||||||
|
this.activeTrackIndex >= this.tracks.length
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -25,7 +25,11 @@ export class Track {
|
|||||||
/**
|
/**
|
||||||
* A result object that contains a collection of images that are available outside the current network.
|
* A result object that contains a collection of images that are available outside the current network.
|
||||||
*/
|
*/
|
||||||
readonly remoteImages?: RemoteImageResult;
|
remoteImages?: RemoteImageResult;
|
||||||
|
|
||||||
|
playing: boolean;
|
||||||
|
|
||||||
|
playbackProgress: number;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
id: string,
|
id: string,
|
||||||
@ -37,6 +41,8 @@ export class Track {
|
|||||||
this.name = name;
|
this.name = name;
|
||||||
this.duration = duration;
|
this.duration = duration;
|
||||||
this.remoteImages = remoteImages;
|
this.remoteImages = remoteImages;
|
||||||
|
this.playing = false;
|
||||||
|
this.playbackProgress = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
getDuration() {
|
getDuration() {
|
||||||
@ -48,6 +54,14 @@ export class Track {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getRemoteImages(): RemoteImageInfo[] {
|
getRemoteImages(): RemoteImageInfo[] {
|
||||||
return this.remoteImages.Images;
|
return this.remoteImages?.Images ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlaybackProgress() {
|
||||||
|
return this.playbackProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePlaybackProgress(progress: number) {
|
||||||
|
this.playbackProgress = progress;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||||
|
|
||||||
import { Playlist } from '../models/shared/Playlist';
|
import { Playlist } from '../models/shared/Playlist';
|
||||||
|
|
||||||
@ -18,4 +18,14 @@ export class PlaybackService {
|
|||||||
this.playlist = new Playlist(this.eventEmitter);
|
this.playlist = new Playlist(this.eventEmitter);
|
||||||
return this.playlist;
|
return this.playlist;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OnEvent('internal.audio.track.previous')
|
||||||
|
private handlePreviousTrackEvent() {
|
||||||
|
this.getPlaylistOrDefault().setPreviousTrackAsActiveTrack();
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent('internal.audio.track.next')
|
||||||
|
private handleNextTrackEvent() {
|
||||||
|
this.getPlaylistOrDefault().setNextTrackAsActiveTrack();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -30,7 +30,7 @@ export class PlayNowCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getSelection(): string[] {
|
getSelection(): string[] {
|
||||||
if (this.hasSelection()) {
|
if (this.hasSelection() && this.StartIndex !== undefined) {
|
||||||
return [this.ItemIds[this.StartIndex]];
|
return [this.ItemIds[this.StartIndex]];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Client, GuildMember } from 'discord.js';
|
import { Client, GuildMember } from 'discord.js';
|
||||||
|
import { Constants } from '../utils/constants';
|
||||||
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
||||||
import { GithubRelease } from '../models/github-release';
|
import { GithubRelease } from '../models/github-release';
|
||||||
import { useDefaultMockerToken } from '../utils/tests/defaultMockerToken';
|
import { useDefaultMockerToken } from '../utils/tests/defaultMockerToken';
|
||||||
import { UpdatesService } from './updates.service';
|
import { UpdatesService } from './updates.service';
|
||||||
|
import { InjectionToken } from '@nestjs/common';
|
||||||
|
|
||||||
// mock axios: https://stackoverflow.com/questions/51275434/type-of-axios-mock-using-jest-typescript/55351900#55351900
|
// mock axios: https://stackoverflow.com/questions/51275434/type-of-axios-mock-using-jest-typescript/55351900#55351900
|
||||||
jest.mock('axios');
|
jest.mock('axios');
|
||||||
@ -14,7 +16,6 @@ describe('UpdatesService', () => {
|
|||||||
const OLD_ENV = process.env;
|
const OLD_ENV = process.env;
|
||||||
|
|
||||||
let updatesService: UpdatesService;
|
let updatesService: UpdatesService;
|
||||||
let discordClient: Client;
|
|
||||||
let discordMessageService: DiscordMessageService;
|
let discordMessageService: DiscordMessageService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@ -33,7 +34,7 @@ describe('UpdatesService', () => {
|
|||||||
} as DiscordMessageService;
|
} as DiscordMessageService;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token === Client || token == '__inject_discord_client__') {
|
if (token === Client || token === '__inject_discord_client__') {
|
||||||
return {
|
return {
|
||||||
guilds: {
|
guilds: {
|
||||||
cache: [
|
cache: [
|
||||||
@ -49,12 +50,11 @@ describe('UpdatesService', () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return useDefaultMockerToken(token);
|
return useDefaultMockerToken(token as InjectionToken);
|
||||||
})
|
})
|
||||||
.compile();
|
.compile();
|
||||||
|
|
||||||
updatesService = moduleRef.get<UpdatesService>(UpdatesService);
|
updatesService = moduleRef.get<UpdatesService>(UpdatesService);
|
||||||
discordClient = moduleRef.get<Client>('__inject_discord_client__');
|
|
||||||
discordMessageService = moduleRef.get<DiscordMessageService>(
|
discordMessageService = moduleRef.get<DiscordMessageService>(
|
||||||
DiscordMessageService,
|
DiscordMessageService,
|
||||||
);
|
);
|
||||||
@ -88,6 +88,12 @@ describe('UpdatesService', () => {
|
|||||||
|
|
||||||
it('handleCronShouldNotifyWhenNewRelease', async () => {
|
it('handleCronShouldNotifyWhenNewRelease', async () => {
|
||||||
process.env.UPDATER_DISABLE_NOTIFICATIONS = 'false';
|
process.env.UPDATER_DISABLE_NOTIFICATIONS = 'false';
|
||||||
|
Constants.Metadata.Version = {
|
||||||
|
All: () => '0.0.5',
|
||||||
|
Major: 0,
|
||||||
|
Minor: 0,
|
||||||
|
Patch: 5,
|
||||||
|
};
|
||||||
|
|
||||||
mockedAxios.mockResolvedValue({
|
mockedAxios.mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
|
@ -30,6 +30,14 @@ export class UpdatesService {
|
|||||||
this.logger.debug('Checking for available updates...');
|
this.logger.debug('Checking for available updates...');
|
||||||
|
|
||||||
const latestGitHubRelease = await this.fetchLatestGithubRelease();
|
const latestGitHubRelease = await this.fetchLatestGithubRelease();
|
||||||
|
|
||||||
|
if (!latestGitHubRelease) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Aborting update check because api request failed. Please check your internet connection or disable the check`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const currentVersion = Constants.Metadata.Version.All();
|
const currentVersion = Constants.Metadata.Version.All();
|
||||||
|
|
||||||
if (latestGitHubRelease.tag_name <= currentVersion) {
|
if (latestGitHubRelease.tag_name <= currentVersion) {
|
||||||
@ -95,21 +103,21 @@ export class UpdatesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchLatestGithubRelease(): Promise<null | GithubRelease> {
|
private async fetchLatestGithubRelease(): Promise<GithubRelease | undefined> {
|
||||||
return axios({
|
return axios({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: Constants.Links.Api.GetLatestRelease,
|
url: Constants.Links.Api.GetLatestRelease,
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.status !== 200) {
|
if (response.status !== 200) {
|
||||||
return null;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.data as GithubRelease;
|
return response.data as GithubRelease;
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
this.logger.error('Error while checking for updates', err);
|
this.logger.error('Error while checking for updates', err);
|
||||||
return null;
|
return undefined;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,3 +11,6 @@ export const trimStringToFixedLength = (value: string, maxLength: number) => {
|
|||||||
|
|
||||||
return value.substring(0, upperBound) + '...';
|
return value.substring(0, upperBound) + '...';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const zeroPad = (num: number, places: number) =>
|
||||||
|
String(num).padStart(places, '0');
|
||||||
|
@ -15,3 +15,7 @@ export const formatMillisecondsAsHumanReadable = (
|
|||||||
);
|
);
|
||||||
return duration;
|
return duration;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function sleep(ms: number) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
15
src/utils/trackConverter.ts
Normal file
15
src/utils/trackConverter.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { JellyfinSearchService } from 'src/clients/jellyfin/jellyfin.search.service';
|
||||||
|
import { SearchHint } from 'src/models/search/SearchHint';
|
||||||
|
import { Track } from 'src/models/shared/Track';
|
||||||
|
|
||||||
|
export const convertToTracks = (
|
||||||
|
hints: SearchHint[],
|
||||||
|
jellyfinSearchService: JellyfinSearchService,
|
||||||
|
): Track[] => {
|
||||||
|
let tracks: Track[] = [];
|
||||||
|
hints.forEach(async (hint) => {
|
||||||
|
const searchedTracks = await hint.toTracks(jellyfinSearchService);
|
||||||
|
tracks = [...tracks, ...searchedTracks];
|
||||||
|
});
|
||||||
|
return tracks;
|
||||||
|
};
|
@ -12,7 +12,7 @@
|
|||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strictNullChecks": false,
|
"strictNullChecks": true,
|
||||||
"noImplicitAny": false,
|
"noImplicitAny": false,
|
||||||
"strictBindCallApply": false,
|
"strictBindCallApply": false,
|
||||||
"forceConsistentCasingInFileNames": false,
|
"forceConsistentCasingInFileNames": false,
|
||||||
|
Loading…
Reference in New Issue
Block a user