diff --git a/.deepsource.toml b/.deepsource.toml
new file mode 100644
index 0000000..0c1cb01
--- /dev/null
+++ b/.deepsource.toml
@@ -0,0 +1,14 @@
+version = 1
+
+[[analyzers]]
+name = "javascript"
+
+ [analyzers.meta]
+ plugins = ["react"]
+
+[[analyzers]]
+name = "docker"
+
+[[analyzers]]
+name = "test-coverage"
+enabled = true
\ No newline at end of file
diff --git a/.github/workflows/deepsource-tests.yml b/.github/workflows/deepsource-tests.yml
new file mode 100644
index 0000000..7b8b523
--- /dev/null
+++ b/.github/workflows/deepsource-tests.yml
@@ -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 /
+ 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 }}
\ No newline at end of file
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
deleted file mode 100644
index efaed3a..0000000
--- a/.github/workflows/sonarcloud.yml
+++ /dev/null
@@ -1,85 +0,0 @@
-# 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.
-
-# This workflow helps you trigger a SonarCloud analysis of your code and populates
-# GitHub Code Scanning alerts with the vulnerabilities found.
-# Free for open source project.
-
-# 1. Login to SonarCloud.io using your GitHub account
-
-# 2. Import your project on SonarCloud
-# * Add your GitHub organization first, then add your repository as a new project.
-# * Please note that many languages are eligible for automatic analysis,
-# which means that the analysis will start automatically without the need to set up GitHub Actions.
-# * This behavior can be changed in Administration > Analysis Method.
-#
-# 3. Follow the SonarCloud in-product tutorial
-# * a. Copy/paste the Project Key and the Organization Key into the args parameter below
-# (You'll find this information in SonarCloud. Click on "Information" at the bottom left)
-#
-# * b. Generate a new token and add it to your Github repository's secrets using the name SONAR_TOKEN
-# (On SonarCloud, click on your avatar on top-right > My account > Security
-# or go directly to https://sonarcloud.io/account/security/)
-
-# Feel free to take a look at our documentation (https://docs.sonarcloud.io/getting-started/github/)
-# or reach out to our community forum if you need some help (https://community.sonarsource.com/c/help/sc/9)
-
-name: SonarCloud analysis
-
-on:
- push:
- branches: [ "master", "dev" ]
- pull_request:
- branches: [ "master", "dev" ]
- workflow_dispatch:
-
-permissions:
- pull-requests: read # allows SonarCloud to decorate PRs with analysis results
-
-jobs:
- Analysis:
- runs-on: ubuntu-latest
-
- 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
- - name: Analyze with SonarCloud
-
- # You can pin the exact commit or the version.
- # uses: SonarSource/sonarcloud-github-action@de2e56b42aa84d0b1c5b622644ac17e505c9a049
- uses: SonarSource/sonarcloud-github-action@de2e56b42aa84d0b1c5b622644ac17e505c9a049
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information
- SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} # Generate a token on Sonarcloud.io, add it to the secrets of this repo with the name SONAR_TOKEN (Settings > Secrets > Actions > add new repository secret)
- with:
- # Additional arguments for the sonarcloud scanner
- args:
- # Unique keys of your project and organization. You can find them in SonarCloud > Information (bottom-left menu)
- # mandatory
- -Dsonar.projectKey=manuel-rw_jellyfin-discord-music-bot
- -Dsonar.organization=manuel-rw
- -Dsonar.javascript.lcov.reportPaths=coverage/lcov.info
- # Comma-separated paths to directories containing main source files.
- #-Dsonar.sources= # optional, default is project base directory
- # When you need the analysis to take place in a directory other than the one from which it was launched
- #-Dsonar.projectBaseDir= # optional, default is .
- # Comma-separated paths to directories containing test source files.
- #-Dsonar.tests= # optional. For more info about Code Coverage, please refer to https://docs.sonarcloud.io/enriching/test-coverage/overview/
- # Adds more detail to both client and server-side analysis logs, activating DEBUG mode for the scanner, and adding client-side environment variables and system properties to the server-side log of analysis report processing.
- #-Dsonar.verbose= # optional, default is false
diff --git a/README.md b/README.md
index a6316aa..2a04b8b 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,7 @@
+
diff --git a/package.json b/package.json
index 275bca8..a6b3e97 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "jellyfin-discord-music-bot",
- "version": "0.0.6",
- "description": "",
+ "version": "0.0.7",
+ "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",
"private": true,
"license": "MIT",
@@ -24,49 +24,50 @@
"@discord-nestjs/common": "^5.2.3",
"@discord-nestjs/core": "^5.3.5",
"@discordjs/opus": "^0.9.0",
- "@discordjs/voice": "^0.15.0",
- "@jellyfin/sdk": "^0.7.0",
- "@nestjs/common": "^9.3.12",
+ "@discordjs/voice": "^0.16.0",
+ "@jellyfin/sdk": "^0.8.1",
+ "@nestjs/common": "^9.4.0",
"@nestjs/config": "^2.2.0",
- "@nestjs/core": "^9.3.12",
+ "@nestjs/core": "^9.4.0",
"@nestjs/event-emitter": "^1.3.1",
"@nestjs/platform-express": "^9.3.12",
"@nestjs/schedule": "^2.1.0",
"@nestjs/serve-static": "^3.0.1",
- "@nestjs/terminus": "^9.1.4",
+ "@nestjs/terminus": "^9.2.2",
"date-fns": "^2.29.3",
- "discord.js": "^14.8.0",
+ "discord.js": "^14.9.0",
"joi": "^17.9.1",
"libsodium-wrappers": "^0.7.10",
"opusscript": "^0.0.8",
"reflect-metadata": "^0.1.13",
- "rimraf": "^4.4.1",
+ "rimraf": "^5.0.0",
"rxjs": "^7.2.0",
"uuid": "^9.0.0",
- "ws": "^8.13.0"
+ "ws": "^8.13.0",
+ "zod": "^3.21.4"
},
"devDependencies": {
"@nestjs/cli": "^9.3.0",
- "@nestjs/schematics": "^9.0.0",
- "@nestjs/testing": "^9.3.12",
+ "@nestjs/schematics": "^9.1.0",
+ "@nestjs/testing": "^9.4.0",
"@types/cron": "^2.0.1",
"@types/express": "^4.17.13",
"@types/jest": "28.1.8",
- "@types/node": "^18.15.10",
+ "@types/node": "^18.15.11",
"@types/supertest": "^2.0.11",
- "@typescript-eslint/eslint-plugin": "^5.56.0",
- "@typescript-eslint/parser": "^5.56.0",
- "eslint": "^8.35.0",
+ "@typescript-eslint/eslint-plugin": "^5.57.0",
+ "@typescript-eslint/parser": "^5.57.0",
+ "eslint": "^8.38.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.0.0",
"jest": "28.1.3",
- "prettier": "^2.8.4",
+ "prettier": "^2.8.7",
"source-map-support": "^0.5.20",
"supertest": "^6.1.3",
"ts-jest": "28.0.8",
"ts-loader": "^9.2.3",
"ts-node": "^10.0.0",
- "tsconfig-paths": "4.1.2",
+ "tsconfig-paths": "4.2.0",
"typescript": "^4.7.4"
},
"jest": {
diff --git a/src/app.module.ts b/src/app.module.ts
index f860303..8773310 100644
--- a/src/app.module.ts
+++ b/src/app.module.ts
@@ -28,6 +28,7 @@ import { UpdatesModule } from './updates/updates.module';
UPDATER_DISABLE_NOTIFICATIONS: Joi.boolean(),
LOG_LEVEL: Joi.string()
.valid('error', 'warn', 'log', 'debug', 'verbose')
+ .insensitive()
.default('log'),
PORT: Joi.number().min(1),
}),
diff --git a/src/clients/discord/discord.config.service.ts b/src/clients/discord/discord.config.service.ts
index 870d5ee..b73cf52 100644
--- a/src/clients/discord/discord.config.service.ts
+++ b/src/clients/discord/discord.config.service.ts
@@ -9,7 +9,7 @@ import { GatewayIntentBits } from 'discord.js';
export class DiscordConfigService implements DiscordOptionsFactory {
createDiscordOptions(): DiscordModuleOption {
return {
- token: process.env.DISCORD_CLIENT_TOKEN,
+ token: process.env.DISCORD_CLIENT_TOKEN ?? '',
discordClientOptions: {
intents: [
GatewayIntentBits.Guilds,
diff --git a/src/clients/discord/discord.voice.service.ts b/src/clients/discord/discord.voice.service.ts
index 615fbc9..db81906 100644
--- a/src/clients/discord/discord.voice.service.ts
+++ b/src/clients/discord/discord.voice.service.ts
@@ -9,20 +9,20 @@ import {
joinVoiceChannel,
NoSubscriberBehavior,
VoiceConnection,
- VoiceConnectionStatus,
} from '@discordjs/voice';
import { Injectable } from '@nestjs/common';
import { Logger } from '@nestjs/common/services';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
+import { Interval } from '@nestjs/schedule';
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 { 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';
@@ -41,7 +41,7 @@ export class DiscordVoiceService {
private readonly eventEmitter: EventEmitter2,
) {}
- @OnEvent('internal.audio.announce')
+ @OnEvent('internal.audio.track.announce')
handleOnNewTrack(track: Track) {
const resource = createAudioResource(
track.getStreamUrl(this.jellyfinStreamBuilder),
@@ -93,7 +93,7 @@ export class DiscordVoiceService {
this.jellyfinWebSocketService.initializeAndConnect();
- if (this.voiceConnection == undefined) {
+ if (this.voiceConnection === undefined) {
this.voiceConnection = getVoiceConnection(member.guild.id);
}
@@ -104,6 +104,12 @@ 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);
}
@@ -116,6 +122,7 @@ export class DiscordVoiceService {
/**
* Pauses the current audio player
*/
+ @OnEvent('internal.voice.controls.pause')
pause() {
this.createAndReturnOrGetAudioPlayer().pause();
this.eventEmitter.emit('playback.state.pause', true);
@@ -124,6 +131,7 @@ export class DiscordVoiceService {
/**
* Stops the audio player
*/
+ @OnEvent('internal.voice.controls.stop')
stop(force: boolean): boolean {
const stopped = this.createAndReturnOrGetAudioPlayer().stop(force);
this.eventEmitter.emit('playback.state.stop');
@@ -161,6 +169,7 @@ export class DiscordVoiceService {
* 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
*/
+ @OnEvent('internal.voice.controls.togglePause')
togglePaused(): boolean {
if (this.isPaused()) {
this.unpause();
@@ -216,7 +225,7 @@ export class DiscordVoiceService {
if (this.audioPlayer === undefined) {
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({
debug: process.env.DEBUG?.toLowerCase() === 'true',
@@ -233,6 +242,20 @@ export class DiscordVoiceService {
}
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) => {
if (process.env.DEBUG?.toLowerCase() !== 'true') {
return;
@@ -250,6 +273,13 @@ export class DiscordVoiceService {
this.logger.error(message);
});
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(
`Audio player changed state from ${previousState.status} to ${this.audioPlayer.state.status}`,
);
@@ -262,22 +292,60 @@ export class DiscordVoiceService {
return;
}
- this.logger.debug(`Audio player finished playing old resource`);
+ this.logger.debug('Audio player finished playing old resource');
- const hasNextTrack = this.playbackService
- .getPlaylistOrDefault()
- .hasNextTrackInPlaylist();
+ const playlist = this.playbackService.getPlaylistOrDefault();
+ const finishedTrack = playlist.getActiveTrack();
+
+ if (finishedTrack) {
+ finishedTrack.playing = false;
+ this.eventEmitter.emit('internal.audio.track.finish', finishedTrack);
+ }
+
+ const hasNextTrack = playlist.hasNextTrackInPlaylist();
this.logger.debug(
`Playlist has next track: ${hasNextTrack ? 'yes' : 'no'}`,
);
if (!hasNextTrack) {
- this.logger.debug(`Reached the end of the playlist`);
+ this.logger.debug('Reached the end of the playlist');
return;
}
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}`,
+ );
+ }
}
diff --git a/src/clients/jellyfin/jellyfin.playstate.service.ts b/src/clients/jellyfin/jellyfin.playstate.service.ts
index 57b1483..ae56dc2 100644
--- a/src/clients/jellyfin/jellyfin.playstate.service.ts
+++ b/src/clients/jellyfin/jellyfin.playstate.service.ts
@@ -10,9 +10,10 @@ import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api';
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
+import { Interval } from '@nestjs/schedule';
+import { Track } from '../../models/shared/Track';
import { PlaybackService } from '../../playback/playback.service';
-import { Track } from '../../types/track';
@Injectable()
export class JellyinPlaystateService {
@@ -46,12 +47,71 @@ export class JellyinPlaystateService {
this.logger.debug('Reported playback capabilities sucessfully');
}
- @OnEvent('playback.newTrack')
+ @OnEvent('internal.audio.track.announce')
private async onPlaybackNewTrack(track: Track) {
+ this.logger.debug(`Reporting playback start on track '${track.id}'`);
await this.playstateApi.reportPlaybackStart({
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}`,
+ );
+ }
}
diff --git a/src/clients/jellyfin/jellyfin.search.service.ts b/src/clients/jellyfin/jellyfin.search.service.ts
index 626d73f..318585c 100644
--- a/src/clients/jellyfin/jellyfin.search.service.ts
+++ b/src/clients/jellyfin/jellyfin.search.service.ts
@@ -1,4 +1,5 @@
import {
+ BaseItemDto,
BaseItemKind,
RemoteImageResult,
SearchHint as JellyfinSearchHint,
@@ -37,7 +38,7 @@ export class JellyfinSearchService {
if (includeItemTypes.length === 0) {
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;
- return SearchHints.map((hint) => this.transformToSearchHint(hint)).filter(
- (x) => x !== null,
- );
+ if (!SearchHints) {
+ throw new Error('SearchHints were undefined');
+ }
+
+ return SearchHints.map((hint) =>
+ this.transformToSearchHintFromHint(hint),
+ ).filter((x) => x !== null) as SearchHint[];
} catch (err) {
this.logger.error(`Failed to search on Jellyfin: ${err}`);
return [];
@@ -82,8 +87,15 @@ export class JellyfinSearchService {
return [];
}
+ if (!axiosResponse.data.Items) {
+ this.logger.error(
+ `Jellyfin search returned no items: ${axiosResponse.data}`,
+ );
+ return [];
+ }
+
return axiosResponse.data.Items.map((hint) =>
- SearchHint.constructFromHint(hint),
+ SearchHint.constructFromBaseItem(hint),
);
}
@@ -104,15 +116,22 @@ export class JellyfinSearchService {
return [];
}
- return axiosResponse.data.SearchHints.map((hint) =>
- SearchHint.constructFromHint(hint),
- );
+ if (!axiosResponse.data.SearchHints) {
+ 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(
id: string,
includeItemTypes: BaseItemKind[],
- ): Promise | undefined {
+ ): Promise {
const api = this.jellyfinService.getApi();
const searchApi = getItemsApi(api);
@@ -122,12 +141,35 @@ export class JellyfinSearchService {
includeItemTypes: includeItemTypes,
});
- if (data.Items.length !== 1) {
+ if (!data.Items || data.Items.length !== 1) {
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 {
+ 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 {
@@ -183,18 +225,25 @@ export class JellyfinSearchService {
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(
- `Unabele to retrieve random items from Jellyfin: ${err}`,
+ `Unable to retrieve random items from Jellyfin: ${err}`,
);
return [];
}
}
- private transformToSearchHint(jellyifnHint: JellyfinSearchHint) {
+ private transformToSearchHintFromHint(jellyifnHint: JellyfinSearchHint) {
switch (jellyifnHint.Type) {
case BaseItemKind[BaseItemKind.Audio]:
return SearchHint.constructFromHint(jellyifnHint);
@@ -206,7 +255,23 @@ export class JellyfinSearchService {
this.logger.warn(
`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;
}
}
}
diff --git a/src/clients/jellyfin/jellyfin.service.ts b/src/clients/jellyfin/jellyfin.service.ts
index 3051f12..b049f79 100644
--- a/src/clients/jellyfin/jellyfin.service.ts
+++ b/src/clients/jellyfin/jellyfin.service.ts
@@ -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');
}
authenticate() {
this.api
.authenticateUserByName(
- process.env.JELLYFIN_AUTHENTICATION_USERNAME,
+ process.env.JELLYFIN_AUTHENTICATION_USERNAME ?? '',
process.env.JELLYFIN_AUTHENTICATION_PASSWORD,
)
.then(async (response) => {
- if (response.data.SessionInfo === undefined) {
+ if (response.data.SessionInfo?.UserId === undefined) {
this.logger.error(
`Failed to authenticate with response code ${response.status}: '${response.data}'`,
);
diff --git a/src/clients/jellyfin/jellyfin.websocket.service.ts b/src/clients/jellyfin/jellyfin.websocket.service.ts
index 21bb8d6..136cc69 100644
--- a/src/clients/jellyfin/jellyfin.websocket.service.ts
+++ b/src/clients/jellyfin/jellyfin.websocket.service.ts
@@ -4,8 +4,9 @@ import {
} from '@jellyfin/sdk/lib/generated-client/models';
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
-import { Cron } from '@nestjs/schedule';
import { EventEmitter2 } from '@nestjs/event-emitter';
+import { Cron } from '@nestjs/schedule';
+import { convertToTracks } from 'src/utils/trackConverter';
import { WebSocket } from 'ws';
@@ -14,11 +15,9 @@ import {
PlayNowCommand,
SessionApiSendPlaystateCommandRequest,
} from '../../types/websocket';
-import { Track } from '../../models/shared/Track';
import { JellyfinSearchService } from './jellyfin.search.service';
import { JellyfinService } from './jellyfin.service';
-import { JellyfinStreamBuilderService } from './jellyfin.stream.builder.service';
@Injectable()
export class JellyfinWebSocketService implements OnModuleDestroy {
@@ -28,9 +27,8 @@ export class JellyfinWebSocketService implements OnModuleDestroy {
constructor(
private readonly jellyfinService: JellyfinService,
- private readonly jellyfinSearchService: JellyfinSearchService,
private readonly playbackService: PlaybackService,
- private readonly jellyfinStreamBuilderService: JellyfinStreamBuilderService,
+ private readonly jellyfinSearchService: JellyfinSearchService,
private readonly eventEmitter: EventEmitter2,
) {}
@@ -103,8 +101,12 @@ export class JellyfinWebSocketService implements OnModuleDestroy {
data.hasSelection = PlayNowCommand.prototype.hasSelection;
data.getSelection = PlayNowCommand.prototype.getSelection;
const ids = data.getSelection();
-
- // TODO: Implement this again
+ this.logger.log(
+ `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;
case SessionMessageType[SessionMessageType.Playstate]:
const sendPlaystateCommandRequest =
@@ -124,13 +126,19 @@ export class JellyfinWebSocketService implements OnModuleDestroy {
) {
switch (request.Command) {
case PlaystateCommand.PlayPause:
- this.eventEmitter.emitAsync('playback.control.togglePause');
+ this.eventEmitter.emit('internal.voice.controls.togglePause');
break;
case PlaystateCommand.Pause:
- this.eventEmitter.emitAsync('playback.control.pause');
+ this.eventEmitter.emit('internal.voice.controls.pause');
break;
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;
default:
this.logger.warn(
diff --git a/src/commands/play/play.comands.ts b/src/commands/play/play.comands.ts
index 0e72eb4..d1d3b2a 100644
--- a/src/commands/play/play.comands.ts
+++ b/src/commands/play/play.comands.ts
@@ -27,7 +27,7 @@ import { DiscordVoiceService } from '../../clients/discord/discord.voice.service
import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service';
import { SearchHint } from '../../models/search/SearchHint';
-import { SearchType, PlayCommandParams } from './play.params.ts';
+import { PlayCommandParams, SearchType } from './play.params.ts';
@Injectable()
@Command({
@@ -48,12 +48,12 @@ export class PlayItemCommand {
async handler(
@InteractionEvent(SlashCommandPipe) dto: PlayCommandParams,
@IA() interaction: CommandInteraction,
- ): Promise {
+ ) {
await interaction.deferReply({ ephemeral: true });
const baseItems = PlayCommandParams.getBaseItemKinds(dto.type);
- let item: SearchHint;
+ let item: SearchHint | undefined;
if (dto.name.startsWith('native-')) {
item = await this.jellyfinSearchService.getById(
dto.name.replace('native-', ''),
@@ -70,7 +70,8 @@ export class PlayItemCommand {
embeds: [
this.discordMessageService.buildMessage({
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,
@@ -104,9 +105,9 @@ export class PlayItemCommand {
);
this.playbackService.getPlaylistOrDefault().enqueueTracks(tracks);
- const remoteImage: RemoteImageInfo | undefined = tracks
- .flatMap((x) => x.getRemoteImages())
- .find((x) => true);
+ const remoteImages = tracks.flatMap((track) => track.getRemoteImages());
+ const remoteImage: RemoteImageInfo | undefined =
+ remoteImages.length > 0 ? remoteImages[0] : undefined;
await interaction.followUp({
embeds: [
@@ -117,7 +118,7 @@ export class PlayItemCommand {
reducedDuration,
)})`,
mixin(embedBuilder) {
- if (!remoteImage) {
+ if (!remoteImage?.Url) {
return embedBuilder;
}
return embedBuilder.setThumbnail(remoteImage.Url);
@@ -135,8 +136,9 @@ export class PlayItemCommand {
}
const focusedAutoCompleteAction = interaction.options.getFocused(true);
- const typeIndex: number | null = interaction.options.getInteger('type');
- const type = Object.values(SearchType)[typeIndex] as SearchType;
+ const typeIndex = interaction.options.getInteger('type');
+ const type =
+ typeIndex !== null ? Object.values(SearchType)[typeIndex] : undefined;
const searchQuery = focusedAutoCompleteAction.value;
if (!searchQuery || searchQuery.length < 1) {
@@ -154,7 +156,7 @@ export class PlayItemCommand {
const hints = await this.jellyfinSearchService.searchItem(
searchQuery,
20,
- PlayCommandParams.getBaseItemKinds(type),
+ PlayCommandParams.getBaseItemKinds(type as SearchType),
);
if (hints.length === 0) {
diff --git a/src/commands/playlist/playlist.command.ts b/src/commands/playlist/playlist.command.ts
index d244041..2cd41cf 100644
--- a/src/commands/playlist/playlist.command.ts
+++ b/src/commands/playlist/playlist.command.ts
@@ -22,16 +22,19 @@ import {
InteractionUpdateOptions,
} 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 { 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 { PlaylistCommandParams } from './playlist.params';
+import { PlaylistTempCommandData } from './playlist.types';
+import { tr } from 'date-fns/locale';
+import { takeCoverage } from 'v8';
@Injectable()
@Command({
@@ -41,7 +44,7 @@ import { PlaylistCommandParams } from './playlist.params';
@UseInterceptors(CollectorInterceptor)
@UseCollectors(PlaylistInteractionCollector)
export class PlaylistCommand {
- public pageData: Map = new Map();
+ public pageData: Map = new Map();
private readonly logger = new Logger(PlaylistCommand.name);
constructor(
@@ -61,7 +64,10 @@ export class PlaylistCommand {
this.getReplyForPage(page) as InteractionReplyOptions,
);
- this.pageData.set(interaction.id, page);
+ this.pageData.set(interaction.id, {
+ page,
+ interaction,
+ });
this.logger.debug(
`Added '${interaction.id}' as a message id for page storage`,
);
@@ -82,6 +88,36 @@ export class PlaylistCommand {
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(
page: number,
): InteractionReplyOptions | InteractionUpdateOptions {
@@ -176,26 +212,34 @@ export class PlaylistCommand {
);
}
+ const paddingNumber = playlist.getLength() >= 100 ? 3 : 2;
+
const content = chunk
.map((track, index) => {
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 point = `${offset + index + 1}. `;
- point += `**${trimStringToFixedLength(track.name, 30)}**`;
-
+ let line = `\`\`${zeroPad(offset + index + 1, paddingNumber)}.\`\` `;
+ line += this.getTrackName(track, isCurrent) + ' • ';
if (isCurrent) {
- point += ' :loud_sound:';
+ line += lightFormat(track.getPlaybackProgress(), 'mm:ss') + ' / ';
}
-
- point += '\n';
- point += Constants.Design.InvisibleSpace.repeat(2);
- point += formatMillisecondsAsHumanReadable(track.getDuration());
-
- return point;
+ line += lightFormat(track.getDuration(), 'mm:ss');
+ if (isCurrent) {
+ line += ' • (:play_pause:)';
+ }
+ return line;
})
.join('\n');
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;
+ }
}
diff --git a/src/commands/playlist/playlist.interaction-collector.ts b/src/commands/playlist/playlist.interaction-collector.ts
index 462ad16..49e3ca8 100644
--- a/src/commands/playlist/playlist.interaction-collector.ts
+++ b/src/commands/playlist/playlist.interaction-collector.ts
@@ -15,6 +15,7 @@ import {
} from 'discord.js';
import { PlaylistCommand } from './playlist.command';
+import { PlaylistTempCommandData } from './playlist.types';
@Injectable({ scope: Scope.REQUEST })
@InteractionEventCollector({ time: 60 * 1000 })
@@ -30,14 +31,17 @@ export class PlaylistInteractionCollector {
@Filter()
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')
async onCollect(interaction: ButtonInteraction): Promise {
const targetPage = this.getInteraction(interaction);
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) {
@@ -48,14 +52,16 @@ export class PlaylistInteractionCollector {
}
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);
- const reply = this.playlistCommand.getReplyForPage(targetPage);
+ const reply = this.playlistCommand.getReplyForPage(targetPage.page);
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);
if (current === undefined) {
@@ -75,12 +81,18 @@ export class PlaylistInteractionCollector {
switch (interaction.customId) {
case 'playlist-controls-next':
- return current + 1;
+ return {
+ ...current,
+ page: current.page + 1,
+ };
case 'playlist-controls-previous':
- return current - 1;
+ return {
+ ...current,
+ page: current.page - 1,
+ };
default:
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;
}
diff --git a/src/commands/playlist/playlist.types.ts b/src/commands/playlist/playlist.types.ts
new file mode 100644
index 0000000..1858151
--- /dev/null
+++ b/src/commands/playlist/playlist.types.ts
@@ -0,0 +1,6 @@
+import { CommandInteraction } from 'discord.js';
+
+export type PlaylistTempCommandData = {
+ page: number;
+ interaction: CommandInteraction;
+};
diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts
index a868546..5412dc2 100644
--- a/src/commands/status.command.ts
+++ b/src/commands/status.command.ts
@@ -39,7 +39,7 @@ export class StatusCommand {
const status = Status[this.client.ws.status];
const interval = intervalToDuration({
- start: this.client.uptime,
+ start: this.client.uptime ?? 0,
end: 0,
});
const formattedDuration = formatDuration(interval);
diff --git a/src/commands/stop.command.ts b/src/commands/stop.command.ts
index d9d1579..b57b129 100644
--- a/src/commands/stop.command.ts
+++ b/src/commands/stop.command.ts
@@ -30,8 +30,8 @@ export class StopPlaybackCommand {
? 'In addition, your playlist has been cleared'
: 'There is no active track in the queue';
if (hasActiveTrack) {
- this.playbackService.getPlaylistOrDefault().clear();
this.discordVoiceService.stop(false);
+ // this.playbackService.getPlaylistOrDefault().clear();
}
await interaction.reply({
diff --git a/src/commands/volume/volume.command.ts b/src/commands/volume/volume.command.ts
index 3bbe4ab..a777d9c 100644
--- a/src/commands/volume/volume.command.ts
+++ b/src/commands/volume/volume.command.ts
@@ -36,7 +36,7 @@ export class VolumeCommand {
await interaction.editReply({
embeds: [
this.discordMessageService.buildMessage({
- title: `Unable to change your volume`,
+ title: "Unable to change your volume",
description:
'The bot is not playing any music or is not straming to a channel',
}),
diff --git a/src/health/health.controller.spec.ts b/src/health/health.controller.spec.ts
index 7de55d4..7d1cf84 100644
--- a/src/health/health.controller.spec.ts
+++ b/src/health/health.controller.spec.ts
@@ -1,3 +1,4 @@
+import { InjectionToken } from '@nestjs/common';
import {
HealthCheckResult,
HealthCheckService,
@@ -42,10 +43,14 @@ describe('HealthController', () => {
}
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();
diff --git a/src/health/indicators/jeyllfin.indicator.spec.ts b/src/health/indicators/jeyllfin.indicator.spec.ts
index 38b12c7..dcebe2d 100644
--- a/src/health/indicators/jeyllfin.indicator.spec.ts
+++ b/src/health/indicators/jeyllfin.indicator.spec.ts
@@ -1,3 +1,4 @@
+import { InjectionToken } from '@nestjs/common';
import { HealthIndicatorResult } from '@nestjs/terminus';
import { Test } from '@nestjs/testing';
import { JellyfinService } from '../../clients/jellyfin/jellyfin.service';
@@ -16,7 +17,7 @@ describe('JellyfinHealthIndicator', () => {
if (token === JellyfinService) {
return { isConnected: jest.fn() };
}
- return useDefaultMockerToken(token);
+ return useDefaultMockerToken(token as InjectionToken);
})
.compile();
diff --git a/src/main.ts b/src/main.ts
index 088755b..4fc280c 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -4,6 +4,10 @@ import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
function getLoggingLevels(): LogLevel[] {
+ if (!process.env.LOG_LEVEL) {
+ return ['error', 'warn', 'log'];
+ }
+
switch (process.env.LOG_LEVEL.toLowerCase()) {
case 'error':
return ['error'];
diff --git a/src/models/search/AlbumSearchHint.ts b/src/models/search/AlbumSearchHint.ts
index dd4b440..b51a57e 100644
--- a/src/models/search/AlbumSearchHint.ts
+++ b/src/models/search/AlbumSearchHint.ts
@@ -11,6 +11,12 @@ export class AlbumSearchHint extends SearchHint {
}
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);
}
@@ -18,14 +24,13 @@ export class AlbumSearchHint extends SearchHint {
searchService: JellyfinSearchService,
): Promise