This commit is contained in:
Manuel 2023-04-10 10:38:40 +02:00
commit 24cfcb9369
40 changed files with 1350 additions and 682 deletions

14
.deepsource.toml Normal file
View File

@ -0,0 +1,14 @@
version = 1
[[analyzers]]
name = "javascript"
[analyzers.meta]
plugins = ["react"]
[[analyzers]]
name = "docker"
[[analyzers]]
name = "test-coverage"
enabled = true

View File

@ -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
View 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 }}

View File

@ -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/>

View File

@ -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": {

View File

@ -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),
}), }),

View File

@ -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,

View File

@ -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}`,
);
}
} }

View File

@ -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}`,
);
}
} }

View File

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

View File

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

View File

@ -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(

View File

@ -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: [],
}) })

View File

@ -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) {

View File

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

View File

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

View File

@ -0,0 +1,6 @@
import { CommandInteraction } from 'discord.js';
export type PlaylistTempCommandData = {
page: number;
interaction: CommandInteraction;
};

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

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

View File

@ -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);

View File

@ -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({

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

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

View File

@ -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();

View File

@ -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();

View File

@ -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'];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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();
}
} }

View File

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

View File

@ -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: {

View File

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

View File

@ -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');

View File

@ -15,3 +15,7 @@ export const formatMillisecondsAsHumanReadable = (
); );
return duration; return duration;
}; };
export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

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

View File

@ -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,

941
yarn.lock

File diff suppressed because it is too large Load Diff