mirror of
https://github.com/informaticker/discord-jellyfin-bot.git
synced 2024-11-25 02:51:57 +01:00
🔀 Version 0.0.5
This commit is contained in:
commit
8d53414e3d
3
.github/workflows/docker-dev.yml
vendored
3
.github/workflows/docker-dev.yml
vendored
@ -23,7 +23,7 @@ jobs:
|
|||||||
build-and-push-image:
|
build-and-push-image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
packages: writement work
|
packages: write
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Setup
|
- name: Setup
|
||||||
@ -41,6 +41,7 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-yarn-
|
${{ runner.os }}-yarn-
|
||||||
- run: yarn install --immutable
|
- run: yarn install --immutable
|
||||||
|
- run: yarn test
|
||||||
- run: yarn build
|
- run: yarn build
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
|
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@ -41,8 +41,8 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-yarn-
|
${{ runner.os }}-yarn-
|
||||||
- run: yarn install --immutable
|
- run: yarn install --immutable
|
||||||
- run: yarn build
|
|
||||||
- run: yarn test
|
- run: yarn test
|
||||||
|
- run: yarn build
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v4
|
uses: docker/metadata-action@v4
|
||||||
|
@ -6,7 +6,4 @@ WORKDIR /app
|
|||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
RUN ls -lha
|
|
||||||
RUN ls dist -lha
|
|
||||||
|
|
||||||
CMD ["yarn", "start:prod"]
|
CMD ["yarn", "start:prod"]
|
@ -12,9 +12,12 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/manuel-rw/jellyfin-discord-music-bot/wiki/%F0%9F%9A%80-Installation"><img src="https://img.shields.io/badge/-Installation%20Guide-7289da?style=for-the-badge&logo=markdown" alt="badge" /></a><br/><br/>
|
<a href="https://github.com/manuel-rw/jellyfin-discord-music-bot/wiki/%F0%9F%9A%80-Installation"><img src="https://img.shields.io/badge/-Installation%20Guide-7289da?style=for-the-badge&logo=markdown" alt="badge" /></a>
|
||||||
<a href="https://discord.gg/hRHZ3q3VDX"><img src="https://img.shields.io/badge/-Community%20Discord-7289da?style=for-the-badge&logo=discord" alt="badge" /></a>
|
<a href="https://discord.gg/hRHZ3q3VDX"><img src="https://img.shields.io/badge/-Community%20Discord-7289da?style=for-the-badge&logo=discord" alt="badge" /></a>
|
||||||
<a href='https://ko-fi.com/A0A42YZ7W' target='_blank'><img src="https://img.shields.io/badge/-Buy%20me%20a%20coffee-f1f1f1?style=for-the-badge&logo=kofi" alt="badge" /></a>
|
<a href='https://ko-fi.com/A0A42YZ7W' target='_blank'><img src="https://img.shields.io/badge/-Buy%20me%20a%20coffee-f1f1f1?style=for-the-badge&logo=kofi" alt="badge" /></a>
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<img src="https://github.com/manuel-rw/jellyfin-discord-music-bot/actions/workflows/docker.yml/badge.svg?branch=master" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
0
client/assets/css/index.css
Normal file
0
client/assets/css/index.css
Normal file
52
client/index.html
Normal file
52
client/index.html
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html class="h-full bg-gray-100 dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link href="assets/css/index.css" rel="stylesheet" />
|
||||||
|
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
clifford: '#da373d',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<title>🎉 Jellyfin Discord Bot</title>
|
||||||
|
</head>
|
||||||
|
<body class="h-full">
|
||||||
|
<div class="min-h-full bg-zinc-900">
|
||||||
|
<nav class="bg-zinc-800">
|
||||||
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex h-16 items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<img
|
||||||
|
class="h-8 w-8"
|
||||||
|
src="https://raw.githubusercontent.com/manuel-rw/jellyfin-discord-music-bot/master/images/icons/jellyfin-icon-squared.png"
|
||||||
|
alt="Jellyfin logo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="ml-5 text-gray-300 text-lg">Discord Bot</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-7xl py-6 px-4 sm:px-6 lg:px-8">
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight text-gray-100 text-center">
|
||||||
|
Jellyfin Discord Bot
|
||||||
|
</h1>
|
||||||
|
<h3 class="text-2xl text-gray-300 text-center">
|
||||||
|
Congratulations, your bot is up and running!
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
8
client/tailwind.config.js
Normal file
8
client/tailwind.config.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ["./src/**/*.{html,js}"],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
9311
package-lock.json
generated
9311
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
29
package.json
29
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jellyfin-discord-music-bot",
|
"name": "jellyfin-discord-music-bot",
|
||||||
"version": "0.0.4",
|
"version": "0.0.5",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "manuel-rw",
|
"author": "manuel-rw",
|
||||||
"private": true,
|
"private": true,
|
||||||
@ -21,10 +21,10 @@
|
|||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discord-nestjs/common": "^4.0.8",
|
"@discord-nestjs/common": "^5.2.2",
|
||||||
"@discord-nestjs/core": "^4.3.1",
|
"@discord-nestjs/core": "^5.3.4",
|
||||||
"@discordjs/opus": "^0.9.0",
|
"@discordjs/opus": "^0.9.0",
|
||||||
"@discordjs/voice": "^0.14.0",
|
"@discordjs/voice": "^0.15.0",
|
||||||
"@jellyfin/sdk": "^0.7.0",
|
"@jellyfin/sdk": "^0.7.0",
|
||||||
"@nestjs/common": "^9.0.0",
|
"@nestjs/common": "^9.0.0",
|
||||||
"@nestjs/config": "^2.2.0",
|
"@nestjs/config": "^2.2.0",
|
||||||
@ -32,29 +32,30 @@
|
|||||||
"@nestjs/event-emitter": "^1.3.1",
|
"@nestjs/event-emitter": "^1.3.1",
|
||||||
"@nestjs/platform-express": "^9.0.0",
|
"@nestjs/platform-express": "^9.0.0",
|
||||||
"@nestjs/schedule": "^2.1.0",
|
"@nestjs/schedule": "^2.1.0",
|
||||||
|
"@nestjs/serve-static": "^3.0.1",
|
||||||
"@nestjs/terminus": "^9.1.4",
|
"@nestjs/terminus": "^9.1.4",
|
||||||
"date-fns": "^2.29.3",
|
"date-fns": "^2.29.3",
|
||||||
"discord.js": "^14.7.1",
|
"discord.js": "^14.8.0",
|
||||||
"joi": "^17.7.0",
|
"joi": "^17.8.4",
|
||||||
"libsodium-wrappers": "^0.7.10",
|
"libsodium-wrappers": "^0.7.10",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rimraf": "^4.1.2",
|
"rimraf": "^4.4.0",
|
||||||
"rxjs": "^7.2.0",
|
"rxjs": "^7.2.0",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
"ws": "^8.11.0"
|
"ws": "^8.13.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^9.1.8",
|
"@nestjs/cli": "^9.2.0",
|
||||||
"@nestjs/schematics": "^9.0.0",
|
"@nestjs/schematics": "^9.0.0",
|
||||||
"@nestjs/testing": "^9.0.0",
|
"@nestjs/testing": "^9.3.9",
|
||||||
"@types/cron": "^2.0.0",
|
"@types/cron": "^2.0.0",
|
||||||
"@types/express": "^4.17.13",
|
"@types/express": "^4.17.13",
|
||||||
"@types/jest": "28.1.8",
|
"@types/jest": "28.1.8",
|
||||||
"@types/node": "^18.11.18",
|
"@types/node": "^18.15.0",
|
||||||
"@types/supertest": "^2.0.11",
|
"@types/supertest": "^2.0.11",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.51.0",
|
"@typescript-eslint/eslint-plugin": "^5.53.0",
|
||||||
"@typescript-eslint/parser": "^5.52.0",
|
"@typescript-eslint/parser": "^5.55.0",
|
||||||
"eslint": "^8.32.0",
|
"eslint": "^8.35.0",
|
||||||
"eslint-config-prettier": "^8.6.0",
|
"eslint-config-prettier": "^8.6.0",
|
||||||
"eslint-plugin-prettier": "^4.0.0",
|
"eslint-plugin-prettier": "^4.0.0",
|
||||||
"jest": "28.1.3",
|
"jest": "28.1.3",
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import * as Joi from 'joi';
|
|
||||||
|
|
||||||
import { DiscordModule } from '@discord-nestjs/core';
|
import { DiscordModule } from '@discord-nestjs/core';
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
|
||||||
|
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||||
|
import { join } from 'path';
|
||||||
import { DiscordConfigService } from './clients/discord/discord.config.service';
|
import { DiscordConfigService } from './clients/discord/discord.config.service';
|
||||||
import { DiscordClientModule } from './clients/discord/discord.module';
|
import { DiscordClientModule } from './clients/discord/discord.module';
|
||||||
import { JellyfinClientModule } from './clients/jellyfin/jellyfin.module';
|
import { JellyfinClientModule } from './clients/jellyfin/jellyfin.module';
|
||||||
@ -23,9 +26,15 @@ import { UpdatesModule } from './updates/updates.module';
|
|||||||
JELLYFIN_AUTHENTICATION_USERNAME: Joi.string().required(),
|
JELLYFIN_AUTHENTICATION_USERNAME: Joi.string().required(),
|
||||||
JELLYFIN_AUTHENTICATION_PASSWORD: Joi.string().required(),
|
JELLYFIN_AUTHENTICATION_PASSWORD: Joi.string().required(),
|
||||||
UPDATER_DISABLE_NOTIFICATIONS: Joi.boolean(),
|
UPDATER_DISABLE_NOTIFICATIONS: Joi.boolean(),
|
||||||
|
LOG_LEVEL: Joi.string()
|
||||||
|
.valid('error', 'warn', 'log', 'debug', 'verbose')
|
||||||
|
.default('log'),
|
||||||
PORT: Joi.number().min(1),
|
PORT: Joi.number().min(1),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
ServeStaticModule.forRoot({
|
||||||
|
rootPath: join(__dirname, '..', 'client'),
|
||||||
|
}),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
DiscordModule.forRootAsync({
|
DiscordModule.forRootAsync({
|
||||||
useClass: DiscordConfigService,
|
useClass: DiscordConfigService,
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { APIEmbed, EmbedBuilder } from 'discord.js';
|
|
||||||
import { DefaultJellyfinColor, ErrorJellyfinColor } from '../../types/colors';
|
|
||||||
|
|
||||||
import { formatRFC7231 } from 'date-fns';
|
import { APIEmbed, EmbedBuilder } from 'discord.js';
|
||||||
|
|
||||||
|
import { DefaultJellyfinColor, ErrorJellyfinColor } from '../../types/colors';
|
||||||
import { Constants } from '../../utils/constants';
|
import { Constants } from '../../utils/constants';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -14,7 +14,6 @@ export class DiscordMessageService {
|
|||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}): APIEmbed {
|
}): APIEmbed {
|
||||||
const date = formatRFC7231(new Date());
|
|
||||||
return this.buildMessage({
|
return this.buildMessage({
|
||||||
title: title,
|
title: title,
|
||||||
description: description,
|
description: description,
|
||||||
@ -25,7 +24,7 @@ export class DiscordMessageService {
|
|||||||
iconURL: Constants.Design.Icons.ErrorIcon,
|
iconURL: Constants.Design.Icons.ErrorIcon,
|
||||||
})
|
})
|
||||||
.setFooter({
|
.setFooter({
|
||||||
text: `${date} - Report an issue: ${Constants.Links.ReportIssue}`,
|
text: `Report this issue: ${Constants.Links.ReportIssue}`,
|
||||||
})
|
})
|
||||||
.setColor(ErrorJellyfinColor);
|
.setColor(ErrorJellyfinColor);
|
||||||
},
|
},
|
||||||
@ -43,17 +42,12 @@ export class DiscordMessageService {
|
|||||||
authorUrl?: string;
|
authorUrl?: string;
|
||||||
mixin?: (embedBuilder: EmbedBuilder) => EmbedBuilder;
|
mixin?: (embedBuilder: EmbedBuilder) => EmbedBuilder;
|
||||||
}): APIEmbed {
|
}): APIEmbed {
|
||||||
const date = formatRFC7231(new Date());
|
|
||||||
|
|
||||||
let embedBuilder = new EmbedBuilder()
|
let embedBuilder = new EmbedBuilder()
|
||||||
.setColor(DefaultJellyfinColor)
|
.setColor(DefaultJellyfinColor)
|
||||||
.setAuthor({
|
.setAuthor({
|
||||||
name: title,
|
name: title,
|
||||||
iconURL: Constants.Design.Icons.JellyfinLogo,
|
iconURL: Constants.Design.Icons.JellyfinLogo,
|
||||||
url: authorUrl,
|
url: authorUrl,
|
||||||
})
|
|
||||||
.setFooter({
|
|
||||||
text: `${date}`,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (description !== undefined && description.length >= 1) {
|
if (description !== undefined && description.length >= 1) {
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { registerFilterGlobally } from '@discord-nestjs/core';
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { OnModuleDestroy } from '@nestjs/common/interfaces/hooks';
|
import { OnModuleDestroy } from '@nestjs/common/interfaces/hooks';
|
||||||
import { CommandExecutionError } from '../../middleware/command-execution-filter';
|
|
||||||
import { PlaybackModule } from '../../playback/playback.module';
|
|
||||||
import { JellyfinClientModule } from '../jellyfin/jellyfin.module';
|
import { JellyfinClientModule } from '../jellyfin/jellyfin.module';
|
||||||
|
import { PlaybackModule } from '../../playback/playback.module';
|
||||||
|
|
||||||
import { DiscordConfigService } from './discord.config.service';
|
import { DiscordConfigService } from './discord.config.service';
|
||||||
import { DiscordMessageService } from './discord.message.service';
|
import { DiscordMessageService } from './discord.message.service';
|
||||||
import { DiscordVoiceService } from './discord.voice.service';
|
import { DiscordVoiceService } from './discord.voice.service';
|
||||||
@ -11,15 +11,7 @@ import { DiscordVoiceService } from './discord.voice.service';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [PlaybackModule, JellyfinClientModule],
|
imports: [PlaybackModule, JellyfinClientModule],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [DiscordConfigService, DiscordVoiceService, DiscordMessageService],
|
||||||
DiscordConfigService,
|
|
||||||
DiscordVoiceService,
|
|
||||||
DiscordMessageService,
|
|
||||||
{
|
|
||||||
provide: registerFilterGlobally(),
|
|
||||||
useClass: CommandExecutionError,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
exports: [DiscordConfigService, DiscordVoiceService, DiscordMessageService],
|
exports: [DiscordConfigService, DiscordVoiceService, DiscordMessageService],
|
||||||
})
|
})
|
||||||
export class DiscordClientModule implements OnModuleDestroy {
|
export class DiscordClientModule implements OnModuleDestroy {
|
||||||
|
@ -7,16 +7,23 @@ import {
|
|||||||
getVoiceConnection,
|
getVoiceConnection,
|
||||||
getVoiceConnections,
|
getVoiceConnections,
|
||||||
joinVoiceChannel,
|
joinVoiceChannel,
|
||||||
|
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 { GuildMember } from 'discord.js';
|
import { GuildMember } from 'discord.js';
|
||||||
|
|
||||||
|
import { JellyfinStreamBuilderService } from '../jellyfin/jellyfin.stream.builder.service';
|
||||||
|
import { JellyfinWebSocketService } from '../jellyfin/jellyfin.websocket.service';
|
||||||
import { GenericTryHandler } from '../../models/generic-try-handler';
|
import { GenericTryHandler } from '../../models/generic-try-handler';
|
||||||
import { PlaybackService } from '../../playback/playback.service';
|
import { PlaybackService } from '../../playback/playback.service';
|
||||||
import { Track } from '../../types/track';
|
import { Track } from '../../models/shared/Track';
|
||||||
import { JellyfinWebSocketService } from '../jellyfin/jellyfin.websocket.service';
|
|
||||||
import { DiscordMessageService } from './discord.message.service';
|
import { DiscordMessageService } from './discord.message.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -29,12 +36,15 @@ export class DiscordVoiceService {
|
|||||||
private readonly discordMessageService: DiscordMessageService,
|
private readonly discordMessageService: DiscordMessageService,
|
||||||
private readonly playbackService: PlaybackService,
|
private readonly playbackService: PlaybackService,
|
||||||
private readonly jellyfinWebSocketService: JellyfinWebSocketService,
|
private readonly jellyfinWebSocketService: JellyfinWebSocketService,
|
||||||
|
private readonly jellyfinStreamBuilder: JellyfinStreamBuilderService,
|
||||||
private readonly eventEmitter: EventEmitter2,
|
private readonly eventEmitter: EventEmitter2,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@OnEvent('playback.newTrack')
|
@OnEvent('internal.audio.announce')
|
||||||
handleOnNewTrack(newTrack: Track) {
|
handleOnNewTrack(track: Track) {
|
||||||
const resource = createAudioResource(newTrack.streamUrl);
|
const resource = createAudioResource(
|
||||||
|
track.getStreamUrl(this.jellyfinStreamBuilder),
|
||||||
|
);
|
||||||
this.playResource(resource);
|
this.playResource(resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,7 +69,7 @@ export class DiscordVoiceService {
|
|||||||
success: false,
|
success: false,
|
||||||
reply: {
|
reply: {
|
||||||
embeds: [
|
embeds: [
|
||||||
this.discordMessageService.buildErrorMessage({
|
this.discordMessageService.buildMessage({
|
||||||
title: 'Unable to join your channel',
|
title: 'Unable to join your channel',
|
||||||
description:
|
description:
|
||||||
"I am unable to join your channel, because you don't seem to be in a voice channel. Connect to a channel first to use this command",
|
"I am unable to join your channel, because you don't seem to be in a voice channel. Connect to a channel first to use this command",
|
||||||
@ -90,13 +100,13 @@ export class DiscordVoiceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
playResource(resource: AudioResource<unknown>) {
|
playResource(resource: AudioResource<unknown>) {
|
||||||
|
this.logger.debug(`Playing audio resource with volume ${resource.volume}`);
|
||||||
this.createAndReturnOrGetAudioPlayer().play(resource);
|
this.createAndReturnOrGetAudioPlayer().play(resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pauses the current audio player
|
* Pauses the current audio player
|
||||||
*/
|
*/
|
||||||
@OnEvent('playback.control.pause')
|
|
||||||
pause() {
|
pause() {
|
||||||
this.createAndReturnOrGetAudioPlayer().pause();
|
this.createAndReturnOrGetAudioPlayer().pause();
|
||||||
this.eventEmitter.emit('playback.state.pause', true);
|
this.eventEmitter.emit('playback.state.pause', true);
|
||||||
@ -105,7 +115,6 @@ export class DiscordVoiceService {
|
|||||||
/**
|
/**
|
||||||
* Stops the audio player
|
* Stops the audio player
|
||||||
*/
|
*/
|
||||||
@OnEvent('playback.control.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');
|
||||||
@ -143,7 +152,6 @@ 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('playback.control.togglePause')
|
|
||||||
togglePaused(): boolean {
|
togglePaused(): boolean {
|
||||||
if (this.isPaused()) {
|
if (this.isPaused()) {
|
||||||
this.unpause();
|
this.unpause();
|
||||||
@ -191,11 +199,22 @@ export class DiscordVoiceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private createAndReturnOrGetAudioPlayer() {
|
private createAndReturnOrGetAudioPlayer() {
|
||||||
|
if (this.voiceConnection === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
'Voice connection has not been initialized and audio player can\t be created',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.audioPlayer === undefined) {
|
if (this.audioPlayer === undefined) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Initialized new instance of Audio Player 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',
|
||||||
|
behaviors: {
|
||||||
|
noSubscriber: NoSubscriberBehavior.Play,
|
||||||
|
},
|
||||||
|
});
|
||||||
this.attachEventListenersToAudioPlayer();
|
this.attachEventListenersToAudioPlayer();
|
||||||
this.voiceConnection.subscribe(this.audioPlayer);
|
this.voiceConnection.subscribe(this.audioPlayer);
|
||||||
return this.audioPlayer;
|
return this.audioPlayer;
|
||||||
@ -205,6 +224,16 @@ export class DiscordVoiceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private attachEventListenersToAudioPlayer() {
|
private attachEventListenersToAudioPlayer() {
|
||||||
|
this.voiceConnection.on('debug', (message) => {
|
||||||
|
if (process.env.DEBUG?.toLowerCase() !== 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logger.debug(message);
|
||||||
|
});
|
||||||
|
this.voiceConnection.on('error', (err) => {
|
||||||
|
this.logger.error(`Voice connection error: ${err}`);
|
||||||
|
});
|
||||||
|
|
||||||
this.audioPlayer.on('debug', (message) => {
|
this.audioPlayer.on('debug', (message) => {
|
||||||
this.logger.debug(message);
|
this.logger.debug(message);
|
||||||
});
|
});
|
||||||
@ -212,6 +241,10 @@ export class DiscordVoiceService {
|
|||||||
this.logger.error(message);
|
this.logger.error(message);
|
||||||
});
|
});
|
||||||
this.audioPlayer.on('stateChange', (previousState) => {
|
this.audioPlayer.on('stateChange', (previousState) => {
|
||||||
|
this.logger.debug(
|
||||||
|
`Audio player changed state from ${previousState.status} to ${this.audioPlayer.state.status}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (previousState.status !== AudioPlayerStatus.Playing) {
|
if (previousState.status !== AudioPlayerStatus.Playing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -220,20 +253,22 @@ export class DiscordVoiceService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasNextTrack = this.playbackService.hasNextTrack();
|
this.logger.debug(`Audio player finished playing old resource`);
|
||||||
|
|
||||||
|
const hasNextTrack = this.playbackService
|
||||||
|
.getPlaylistOrDefault()
|
||||||
|
.hasNextTrackInPlaylist();
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Deteced audio player status change from ${previousState.status} to ${
|
`Playlist has next track: ${hasNextTrack ? 'yes' : 'no'}`,
|
||||||
this.audioPlayer.state.status
|
|
||||||
}. Has next track: ${hasNextTrack ? 'yes' : 'no'}`,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hasNextTrack) {
|
if (!hasNextTrack) {
|
||||||
this.logger.debug(`Audio Player has reached the end of the playlist`);
|
this.logger.debug(`Reached the end of the playlist`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.playbackService.nextTrack();
|
this.playbackService.getPlaylistOrDefault().setNextTrackAsActiveTrack();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { Api } from '@jellyfin/sdk';
|
import { Api } from '@jellyfin/sdk';
|
||||||
import { PlaystateApi } from '@jellyfin/sdk/lib/generated-client/api/playstate-api';
|
import { PlaystateApi } from '@jellyfin/sdk/lib/generated-client/api/playstate-api';
|
||||||
import { SessionApi } from '@jellyfin/sdk/lib/generated-client/api/session-api';
|
import { SessionApi } from '@jellyfin/sdk/lib/generated-client/api/session-api';
|
||||||
@ -9,9 +7,12 @@ import {
|
|||||||
} from '@jellyfin/sdk/lib/generated-client/models';
|
} from '@jellyfin/sdk/lib/generated-client/models';
|
||||||
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api/playstate-api';
|
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api/playstate-api';
|
||||||
import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api';
|
import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api';
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import { Track } from '../../types/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 {
|
||||||
@ -53,35 +54,4 @@ export class JellyinPlaystateService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent('playback.state.pause')
|
|
||||||
private async onPlaybackPaused(isPaused: boolean) {
|
|
||||||
const activeTrack = this.playbackService.getActiveTrack();
|
|
||||||
|
|
||||||
if (!activeTrack) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.playstateApi.reportPlaybackProgress({
|
|
||||||
playbackProgressInfo: {
|
|
||||||
ItemId: activeTrack.track.jellyfinId,
|
|
||||||
IsPaused: isPaused,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnEvent('playback.state.stop')
|
|
||||||
private async onPlaybackStopped() {
|
|
||||||
const activeTrack = this.playbackService.getActiveTrack();
|
|
||||||
|
|
||||||
if (!activeTrack) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.playstateApi.reportPlaybackStopped({
|
|
||||||
playbackStopInfo: {
|
|
||||||
ItemId: activeTrack.track.jellyfinId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,20 +1,21 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { JellyfinService } from './jellyfin.service';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BaseItemKind,
|
BaseItemKind,
|
||||||
RemoteImageResult,
|
RemoteImageResult,
|
||||||
SearchHint,
|
SearchHint as JellyfinSearchHint,
|
||||||
} from '@jellyfin/sdk/lib/generated-client/models';
|
} from '@jellyfin/sdk/lib/generated-client/models';
|
||||||
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api';
|
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api';
|
||||||
import { getPlaylistsApi } from '@jellyfin/sdk/lib/utils/api/playlists-api';
|
import { getPlaylistsApi } from '@jellyfin/sdk/lib/utils/api/playlists-api';
|
||||||
import { getRemoteImageApi } from '@jellyfin/sdk/lib/utils/api/remote-image-api';
|
import { getRemoteImageApi } from '@jellyfin/sdk/lib/utils/api/remote-image-api';
|
||||||
import { getSearchApi } from '@jellyfin/sdk/lib/utils/api/search-api';
|
import { getSearchApi } from '@jellyfin/sdk/lib/utils/api/search-api';
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Logger } from '@nestjs/common/services';
|
import { Logger } from '@nestjs/common/services';
|
||||||
import {
|
|
||||||
JellyfinAudioPlaylist,
|
import { AlbumSearchHint } from '../../models/search/AlbumSearchHint';
|
||||||
JellyfinMusicAlbum,
|
import { PlaylistSearchHint } from '../../models/search/PlaylistSearchHint';
|
||||||
} from '../../models/jellyfinAudioItems';
|
import { SearchHint } from '../../models/search/SearchHint';
|
||||||
|
|
||||||
|
import { JellyfinService } from './jellyfin.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JellyfinSearchService {
|
export class JellyfinSearchService {
|
||||||
@ -22,35 +23,50 @@ export class JellyfinSearchService {
|
|||||||
|
|
||||||
constructor(private readonly jellyfinService: JellyfinService) {}
|
constructor(private readonly jellyfinService: JellyfinService) {}
|
||||||
|
|
||||||
async search(searchTerm: string): Promise<SearchHint[]> {
|
async searchItem(
|
||||||
const api = this.jellyfinService.getApi();
|
searchTerm: string,
|
||||||
|
limit?: number,
|
||||||
this.logger.debug(`Searching for '${searchTerm}'`);
|
includeItemTypes: BaseItemKind[] = [
|
||||||
|
|
||||||
const searchApi = getSearchApi(api);
|
|
||||||
const {
|
|
||||||
data: { SearchHints, TotalRecordCount },
|
|
||||||
status,
|
|
||||||
} = await searchApi.get({
|
|
||||||
searchTerm: searchTerm,
|
|
||||||
includeItemTypes: [
|
|
||||||
BaseItemKind.Audio,
|
BaseItemKind.Audio,
|
||||||
BaseItemKind.MusicAlbum,
|
BaseItemKind.MusicAlbum,
|
||||||
BaseItemKind.Playlist,
|
BaseItemKind.Playlist,
|
||||||
],
|
],
|
||||||
|
): Promise<SearchHint[]> {
|
||||||
|
const api = this.jellyfinService.getApi();
|
||||||
|
const searchApi = getSearchApi(api);
|
||||||
|
|
||||||
|
if (includeItemTypes.length === 0) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Included item types are empty. This may lead to unwanted results`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, status } = await searchApi.get({
|
||||||
|
searchTerm: searchTerm,
|
||||||
|
includeItemTypes: includeItemTypes,
|
||||||
|
limit: limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (status !== 200) {
|
if (status !== 200) {
|
||||||
this.logger.error(`Jellyfin Search failed with status code ${status}`);
|
this.logger.error(
|
||||||
|
`Jellyfin Search failed with status code ${status}: ${data}`,
|
||||||
|
);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(`Found ${TotalRecordCount} results for '${searchTerm}'`);
|
const { SearchHints } = data;
|
||||||
|
|
||||||
return SearchHints;
|
return SearchHints.map((hint) => this.transformToSearchHint(hint)).filter(
|
||||||
|
(x) => x !== null,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Failed to search on Jellyfin: ${err}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPlaylistById(id: string): Promise<JellyfinAudioPlaylist> {
|
async getPlaylistitems(id: string): Promise<SearchHint[]> {
|
||||||
const api = this.jellyfinService.getApi();
|
const api = this.jellyfinService.getApi();
|
||||||
const searchApi = getPlaylistsApi(api);
|
const searchApi = getPlaylistsApi(api);
|
||||||
|
|
||||||
@ -63,13 +79,15 @@ export class JellyfinSearchService {
|
|||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Jellyfin Search failed with status code ${axiosResponse.status}`,
|
`Jellyfin Search failed with status code ${axiosResponse.status}`,
|
||||||
);
|
);
|
||||||
return new JellyfinAudioPlaylist();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return axiosResponse.data as JellyfinAudioPlaylist;
|
return axiosResponse.data.Items.map((hint) =>
|
||||||
|
SearchHint.constructFromHint(hint),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getItemsByAlbum(albumId: string): Promise<JellyfinMusicAlbum> {
|
async getAlbumItems(albumId: string): Promise<SearchHint[]> {
|
||||||
const api = this.jellyfinService.getApi();
|
const api = this.jellyfinService.getApi();
|
||||||
const searchApi = getSearchApi(api);
|
const searchApi = getSearchApi(api);
|
||||||
const axiosResponse = await searchApi.get({
|
const axiosResponse = await searchApi.get({
|
||||||
@ -83,19 +101,25 @@ export class JellyfinSearchService {
|
|||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Jellyfin Search failed with status code ${axiosResponse.status}`,
|
`Jellyfin Search failed with status code ${axiosResponse.status}`,
|
||||||
);
|
);
|
||||||
return new JellyfinMusicAlbum();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return axiosResponse.data as JellyfinMusicAlbum;
|
return axiosResponse.data.SearchHints.map((hint) =>
|
||||||
|
SearchHint.constructFromHint(hint),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getById(id: string): Promise<SearchHint> {
|
async getById(
|
||||||
|
id: string,
|
||||||
|
includeItemTypes: BaseItemKind[],
|
||||||
|
): Promise<SearchHint> | undefined {
|
||||||
const api = this.jellyfinService.getApi();
|
const api = this.jellyfinService.getApi();
|
||||||
|
|
||||||
const searchApi = getItemsApi(api);
|
const searchApi = getItemsApi(api);
|
||||||
const { data } = await searchApi.getItems({
|
const { data } = await searchApi.getItems({
|
||||||
ids: [id],
|
ids: [id],
|
||||||
userId: this.jellyfinService.getUserId(),
|
userId: this.jellyfinService.getUserId(),
|
||||||
|
includeItemTypes: includeItemTypes,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.Items.length !== 1) {
|
if (data.Items.length !== 1) {
|
||||||
@ -103,17 +127,22 @@ export class JellyfinSearchService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return data.Items[0];
|
return this.transformToSearchHint(data.Items[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRemoteImageById(id: string): Promise<RemoteImageResult> {
|
async getRemoteImageById(id: string, limit = 20): Promise<RemoteImageResult> {
|
||||||
const api = this.jellyfinService.getApi();
|
const api = this.jellyfinService.getApi();
|
||||||
const remoteImageApi = getRemoteImageApi(api);
|
const remoteImageApi = getRemoteImageApi(api);
|
||||||
|
|
||||||
|
this.logger.verbose(
|
||||||
|
`Searching for remote images of item '${id}' with limit of ${limit}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
const axiosReponse = await remoteImageApi.getRemoteImages({
|
const axiosReponse = await remoteImageApi.getRemoteImages({
|
||||||
itemId: id,
|
itemId: id,
|
||||||
includeAllLanguages: true,
|
includeAllLanguages: true,
|
||||||
limit: 20,
|
limit: limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (axiosReponse.status !== 200) {
|
if (axiosReponse.status !== 200) {
|
||||||
@ -127,6 +156,33 @@ export class JellyfinSearchService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.logger.verbose(
|
||||||
|
`Retrieved ${axiosReponse.data.TotalRecordCount} remote images from Jellyfin`,
|
||||||
|
);
|
||||||
return axiosReponse.data;
|
return axiosReponse.data;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Failed to retrieve remote images: ${err}`);
|
||||||
|
return {
|
||||||
|
Images: [],
|
||||||
|
Providers: [],
|
||||||
|
TotalRecordCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private transformToSearchHint(jellyifnHint: JellyfinSearchHint) {
|
||||||
|
switch (jellyifnHint.Type) {
|
||||||
|
case BaseItemKind[BaseItemKind.Audio]:
|
||||||
|
return SearchHint.constructFromHint(jellyifnHint);
|
||||||
|
case BaseItemKind[BaseItemKind.MusicAlbum]:
|
||||||
|
return AlbumSearchHint.constructFromHint(jellyifnHint);
|
||||||
|
case BaseItemKind[BaseItemKind.Playlist]:
|
||||||
|
return PlaylistSearchHint.constructFromHint(jellyifnHint);
|
||||||
|
default:
|
||||||
|
this.logger.warn(
|
||||||
|
`Received unexpected item type from Jellyfin search: ${jellyifnHint.Type}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
import { JellyfinService } from './jellyfin.service';
|
import { JellyfinService } from './jellyfin.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -11,7 +12,7 @@ export class JellyfinStreamBuilderService {
|
|||||||
const api = this.jellyfinService.getApi();
|
const api = this.jellyfinService.getApi();
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Attempting to build stream resource for item ${jellyfinItemId} with bitrate ${bitrate}`,
|
`Building stream for '${jellyfinItemId}' with bitrate ${bitrate}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const accessToken = this.jellyfinService.getApi().accessToken;
|
const accessToken = this.jellyfinService.getApi().accessToken;
|
||||||
|
@ -1,19 +1,25 @@
|
|||||||
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
|
||||||
import { Cron } from '@nestjs/schedule';
|
|
||||||
import { JellyfinService } from './jellyfin.service';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
PlaystateCommand,
|
PlaystateCommand,
|
||||||
SessionMessageType,
|
SessionMessageType,
|
||||||
} from '@jellyfin/sdk/lib/generated-client/models';
|
} from '@jellyfin/sdk/lib/generated-client/models';
|
||||||
import { WebSocket } from 'ws';
|
|
||||||
import { PlaybackService } from '../../playback/playback.service';
|
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
||||||
import { JellyfinSearchService } from './jellyfin.search.service';
|
import { Cron } from '@nestjs/schedule';
|
||||||
import { JellyfinStreamBuilderService } from './jellyfin.stream.builder.service';
|
|
||||||
import { Track } from '../../types/track';
|
|
||||||
import { PlayNowCommand, SessionApiSendPlaystateCommandRequest } from '../../types/websocket';
|
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
|
||||||
|
import { WebSocket } from 'ws';
|
||||||
|
|
||||||
|
import { PlaybackService } from '../../playback/playback.service';
|
||||||
|
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()
|
@Injectable()
|
||||||
export class JellyfinWebSocketService implements OnModuleDestroy {
|
export class JellyfinWebSocketService implements OnModuleDestroy {
|
||||||
private webSocket: WebSocket;
|
private webSocket: WebSocket;
|
||||||
@ -82,7 +88,7 @@ export class JellyfinWebSocketService implements OnModuleDestroy {
|
|||||||
return this.webSocket.readyState;
|
return this.webSocket.readyState;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected messageHandler(data: any) {
|
protected async messageHandler(data: any) {
|
||||||
const msg: JellyMessage<unknown> = JSON.parse(data);
|
const msg: JellyMessage<unknown> = JSON.parse(data);
|
||||||
|
|
||||||
switch (msg.MessageType) {
|
switch (msg.MessageType) {
|
||||||
@ -98,42 +104,7 @@ export class JellyfinWebSocketService implements OnModuleDestroy {
|
|||||||
data.getSelection = PlayNowCommand.prototype.getSelection;
|
data.getSelection = PlayNowCommand.prototype.getSelection;
|
||||||
const ids = data.getSelection();
|
const ids = data.getSelection();
|
||||||
|
|
||||||
this.logger.debug(
|
// TODO: Implement this again
|
||||||
`Adding ${ids.length} ids to the queue using controls from the websocket`,
|
|
||||||
);
|
|
||||||
|
|
||||||
ids.forEach((id, index) => {
|
|
||||||
this.jellyfinSearchService
|
|
||||||
.getById(id)
|
|
||||||
.then((response) => {
|
|
||||||
const track: Track = {
|
|
||||||
name: response.Name,
|
|
||||||
durationInMilliseconds: response.RunTimeTicks / 10000,
|
|
||||||
jellyfinId: response.Id,
|
|
||||||
streamUrl: this.jellyfinStreamBuilderService.buildStreamUrl(
|
|
||||||
response.Id,
|
|
||||||
96000,
|
|
||||||
),
|
|
||||||
remoteImages: {
|
|
||||||
Images: [],
|
|
||||||
Providers: [],
|
|
||||||
TotalRecordCount: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const trackId = this.playbackService.enqueueTrack(track);
|
|
||||||
|
|
||||||
if (index !== 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.playbackService.setActiveTrack(trackId);
|
|
||||||
this.playbackService.getActiveTrackAndEmitEvent();
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
this.logger.error(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
case SessionMessageType[SessionMessageType.Playstate]:
|
case SessionMessageType[SessionMessageType.Playstate]:
|
||||||
const sendPlaystateCommandRequest =
|
const sendPlaystateCommandRequest =
|
||||||
|
@ -4,16 +4,17 @@ import { Module } from '@nestjs/common';
|
|||||||
import { DiscordClientModule } from '../clients/discord/discord.module';
|
import { DiscordClientModule } from '../clients/discord/discord.module';
|
||||||
import { JellyfinClientModule } from '../clients/jellyfin/jellyfin.module';
|
import { JellyfinClientModule } from '../clients/jellyfin/jellyfin.module';
|
||||||
import { PlaybackModule } from '../playback/playback.module';
|
import { PlaybackModule } from '../playback/playback.module';
|
||||||
import { PlaylistCommand } from './playlist.command';
|
import { PlaylistCommand } from './playlist/playlist.command';
|
||||||
import { DisconnectCommand } from './disconnect.command';
|
import { DisconnectCommand } from './disconnect.command';
|
||||||
import { HelpCommand } from './help.command';
|
import { HelpCommand } from './help.command';
|
||||||
import { PausePlaybackCommand } from './pause.command';
|
import { PausePlaybackCommand } from './pause.command';
|
||||||
import { PlayItemCommand } from './play.comands';
|
import { PlayItemCommand } from './play/play.comands';
|
||||||
import { PreviousTrackCommand } from './previous.command';
|
import { PreviousTrackCommand } from './previous.command';
|
||||||
import { SkipTrackCommand } from './next.command';
|
import { SkipTrackCommand } from './next.command';
|
||||||
import { StatusCommand } from './status.command';
|
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';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -24,6 +25,7 @@ import { SummonCommand } from './summon.command';
|
|||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [
|
||||||
|
PlaylistInteractionCollector,
|
||||||
HelpCommand,
|
HelpCommand,
|
||||||
StatusCommand,
|
StatusCommand,
|
||||||
PlaylistCommand,
|
PlaylistCommand,
|
||||||
|
@ -1,22 +1,25 @@
|
|||||||
import { TransformPipe } from '@discord-nestjs/common';
|
import { Command, Handler, IA } from '@discord-nestjs/core';
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common/decorators';
|
||||||
|
|
||||||
import { Command, DiscordCommand, UsePipes } from '@discord-nestjs/core';
|
|
||||||
import { CommandInteraction } from 'discord.js';
|
import { CommandInteraction } from 'discord.js';
|
||||||
|
|
||||||
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
||||||
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
|
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
@Command({
|
@Command({
|
||||||
name: 'disconnect',
|
name: 'disconnect',
|
||||||
description: 'Join your current voice channel',
|
description: 'Join your current voice channel',
|
||||||
})
|
})
|
||||||
@UsePipes(TransformPipe)
|
export class DisconnectCommand {
|
||||||
export class DisconnectCommand implements DiscordCommand {
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly discordVoiceService: DiscordVoiceService,
|
private readonly discordVoiceService: DiscordVoiceService,
|
||||||
private readonly discordMessageService: DiscordMessageService,
|
private readonly discordMessageService: DiscordMessageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handler(interaction: CommandInteraction): Promise<void> {
|
@Handler()
|
||||||
|
async handler(@IA() interaction: CommandInteraction): Promise<void> {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
embeds: [
|
embeds: [
|
||||||
this.discordMessageService.buildMessage({
|
this.discordMessageService.buildMessage({
|
||||||
|
@ -1,18 +1,21 @@
|
|||||||
import { TransformPipe } from '@discord-nestjs/common';
|
import { Command, Handler, IA } from '@discord-nestjs/core';
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { Command, DiscordCommand, UsePipes } from '@discord-nestjs/core';
|
|
||||||
import { CommandInteraction } from 'discord.js';
|
import { CommandInteraction } from 'discord.js';
|
||||||
|
|
||||||
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
@Command({
|
@Command({
|
||||||
name: 'help',
|
name: 'help',
|
||||||
description: 'Get help if you're having problems with this bot',
|
description: 'Get help if you're having problems with this bot',
|
||||||
})
|
})
|
||||||
@UsePipes(TransformPipe)
|
export class HelpCommand {
|
||||||
export class HelpCommand implements DiscordCommand {
|
|
||||||
constructor(private readonly discordMessageService: DiscordMessageService) {}
|
constructor(private readonly discordMessageService: DiscordMessageService) {}
|
||||||
|
|
||||||
async handler(interaction: CommandInteraction): Promise<void> {
|
@Handler()
|
||||||
|
async handler(@IA() interaction: CommandInteraction): Promise<void> {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
embeds: [
|
embeds: [
|
||||||
this.discordMessageService.buildMessage({
|
this.discordMessageService.buildMessage({
|
||||||
|
@ -1,23 +1,26 @@
|
|||||||
import { TransformPipe } from '@discord-nestjs/common';
|
import { Command, Handler, IA } from '@discord-nestjs/core';
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { Command, DiscordCommand, UsePipes } from '@discord-nestjs/core';
|
|
||||||
import { CommandInteraction } from 'discord.js';
|
import { CommandInteraction } from 'discord.js';
|
||||||
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
|
||||||
import { PlaybackService } from '../playback/playback.service';
|
import { PlaybackService } from '../playback/playback.service';
|
||||||
|
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
||||||
|
|
||||||
@Command({
|
@Command({
|
||||||
name: 'next',
|
name: 'next',
|
||||||
description: 'Go to the next track in the playlist',
|
description: 'Go to the next track in the playlist',
|
||||||
})
|
})
|
||||||
@UsePipes(TransformPipe)
|
@Injectable()
|
||||||
export class SkipTrackCommand implements DiscordCommand {
|
export class SkipTrackCommand {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly playbackService: PlaybackService,
|
private readonly playbackService: PlaybackService,
|
||||||
private readonly discordMessageService: DiscordMessageService,
|
private readonly discordMessageService: DiscordMessageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handler(interaction: CommandInteraction): Promise<void> {
|
@Handler()
|
||||||
if (!this.playbackService.nextTrack()) {
|
async handler(@IA() interaction: CommandInteraction): Promise<void> {
|
||||||
|
if (!this.playbackService.getPlaylistOrDefault().hasActiveTrack()) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
embeds: [
|
embeds: [
|
||||||
this.discordMessageService.buildErrorMessage({
|
this.discordMessageService.buildErrorMessage({
|
||||||
@ -25,8 +28,10 @@ export class SkipTrackCommand implements DiscordCommand {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.playbackService.getPlaylistOrDefault().setNextTrackAsActiveTrack();
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
embeds: [
|
embeds: [
|
||||||
this.discordMessageService.buildMessage({
|
this.discordMessageService.buildMessage({
|
||||||
|
@ -1,22 +1,25 @@
|
|||||||
import { TransformPipe } from '@discord-nestjs/common';
|
import { Command, Handler, IA } from '@discord-nestjs/core';
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { CommandInteraction } from 'discord.js';
|
||||||
|
|
||||||
import { Command, DiscordCommand, UsePipes } from '@discord-nestjs/core';
|
|
||||||
import { CommandInteraction, InteractionReplyOptions } from 'discord.js';
|
|
||||||
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
||||||
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
|
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
@Command({
|
@Command({
|
||||||
name: 'pause',
|
name: 'pause',
|
||||||
description: 'Pause or resume the playback of the current track',
|
description: 'Pause or resume the playback of the current track',
|
||||||
})
|
})
|
||||||
@UsePipes(TransformPipe)
|
export class PausePlaybackCommand {
|
||||||
export class PausePlaybackCommand implements DiscordCommand {
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly discordVoiceService: DiscordVoiceService,
|
private readonly discordVoiceService: DiscordVoiceService,
|
||||||
private readonly discordMessageService: DiscordMessageService,
|
private readonly discordMessageService: DiscordMessageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handler(interaction: CommandInteraction): Promise<void> {
|
@Handler()
|
||||||
|
async handler(@IA() interaction: CommandInteraction): Promise<void> {
|
||||||
const shouldBePaused = this.discordVoiceService.togglePaused();
|
const shouldBePaused = this.discordVoiceService.togglePaused();
|
||||||
|
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
|
@ -1,319 +0,0 @@
|
|||||||
import { TransformPipe } from '@discord-nestjs/common';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
DiscordTransformedCommand,
|
|
||||||
On,
|
|
||||||
Payload,
|
|
||||||
TransformedCommandExecutionContext,
|
|
||||||
UsePipes,
|
|
||||||
} from '@discord-nestjs/core';
|
|
||||||
import { Logger } from '@nestjs/common/services';
|
|
||||||
import {
|
|
||||||
ComponentType,
|
|
||||||
Events,
|
|
||||||
GuildMember,
|
|
||||||
Interaction,
|
|
||||||
InteractionReplyOptions,
|
|
||||||
} from 'discord.js';
|
|
||||||
import { JellyfinSearchService } from '../clients/jellyfin/jellyfin.search.service';
|
|
||||||
import { TrackRequestDto } from '../models/track-request.dto';
|
|
||||||
|
|
||||||
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
|
||||||
|
|
||||||
import { RemoteImageResult } from '@jellyfin/sdk/lib/generated-client/models';
|
|
||||||
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
|
|
||||||
import { JellyfinStreamBuilderService } from '../clients/jellyfin/jellyfin.stream.builder.service';
|
|
||||||
import {
|
|
||||||
BaseJellyfinAudioPlayable,
|
|
||||||
searchResultAsJellyfinAudio,
|
|
||||||
} from '../models/jellyfinAudioItems';
|
|
||||||
import { PlaybackService } from '../playback/playback.service';
|
|
||||||
import { chooseSuitableRemoteImage } from '../utils/remoteImages/remoteImages';
|
|
||||||
import { trimStringToFixedLength } from '../utils/stringUtils/stringUtils';
|
|
||||||
|
|
||||||
@Command({
|
|
||||||
name: 'play',
|
|
||||||
description: 'Search for an item on your Jellyfin instance',
|
|
||||||
})
|
|
||||||
@UsePipes(TransformPipe)
|
|
||||||
export class PlayItemCommand
|
|
||||||
implements DiscordTransformedCommand<TrackRequestDto>
|
|
||||||
{
|
|
||||||
private readonly logger: Logger = new Logger(PlayItemCommand.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly jellyfinSearchService: JellyfinSearchService,
|
|
||||||
private readonly discordMessageService: DiscordMessageService,
|
|
||||||
private readonly discordVoiceService: DiscordVoiceService,
|
|
||||||
private readonly playbackService: PlaybackService,
|
|
||||||
private readonly jellyfinStreamBuilder: JellyfinStreamBuilderService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async handler(
|
|
||||||
@Payload() dto: TrackRequestDto,
|
|
||||||
executionContext: TransformedCommandExecutionContext<any>,
|
|
||||||
): Promise<InteractionReplyOptions | string> {
|
|
||||||
await executionContext.interaction.deferReply();
|
|
||||||
|
|
||||||
const items = await this.jellyfinSearchService.search(dto.search);
|
|
||||||
const parsedItems = await Promise.all(
|
|
||||||
items.map(
|
|
||||||
async (item) =>
|
|
||||||
await searchResultAsJellyfinAudio(
|
|
||||||
this.logger,
|
|
||||||
this.jellyfinSearchService,
|
|
||||||
item,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (parsedItems.length === 0) {
|
|
||||||
await executionContext.interaction.followUp({
|
|
||||||
embeds: [
|
|
||||||
this.discordMessageService.buildErrorMessage({
|
|
||||||
title: 'No results for your search query found',
|
|
||||||
description: `I was not able to find any matches for your query \`\`${dto.search}\`\`. Please check that I have access to the desired libraries and that your query is not misspelled`,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstItems = parsedItems.slice(0, 10);
|
|
||||||
|
|
||||||
const lines: string[] = firstItems.map((item, index) => {
|
|
||||||
let line = `${index + 1}. `;
|
|
||||||
line += item.prettyPrint(dto.search);
|
|
||||||
return line;
|
|
||||||
});
|
|
||||||
|
|
||||||
let description =
|
|
||||||
'I have found **' +
|
|
||||||
items.length +
|
|
||||||
'** results for your search ``' +
|
|
||||||
dto.search +
|
|
||||||
'``.';
|
|
||||||
|
|
||||||
if (items.length > 10) {
|
|
||||||
description +=
|
|
||||||
'\nSince the results exceed 10 items, I truncated them for better readability.';
|
|
||||||
}
|
|
||||||
|
|
||||||
description += '\n\n' + lines.join('\n');
|
|
||||||
|
|
||||||
const selectOptions: { label: string; value: string; emoji?: string }[] =
|
|
||||||
firstItems.map((item) => ({
|
|
||||||
label: item.prettyPrint(dto.search).replace(/\*/g, ''),
|
|
||||||
value: item.getValueId(),
|
|
||||||
emoji: item.getEmoji(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
await executionContext.interaction.followUp({
|
|
||||||
embeds: [
|
|
||||||
this.discordMessageService.buildMessage({
|
|
||||||
title: 'Jellyfin Search Results',
|
|
||||||
description: description,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
type: ComponentType.ActionRow,
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
type: ComponentType.StringSelect,
|
|
||||||
customId: 'searchItemSelect',
|
|
||||||
options: selectOptions,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@On(Events.InteractionCreate)
|
|
||||||
async onStringSelect(interaction: Interaction) {
|
|
||||||
if (!interaction.isStringSelectMenu()) return;
|
|
||||||
|
|
||||||
if (interaction.customId !== 'searchItemSelect') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.values.length !== 1) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Failed to process interaction select with values [${interaction.values.length}]`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await interaction.deferUpdate();
|
|
||||||
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [
|
|
||||||
this.discordMessageService.buildMessage({
|
|
||||||
title: 'Applying your selection to the queue...',
|
|
||||||
description: `This may take a moment. Please wait`,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
components: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const guildMember = interaction.member as GuildMember;
|
|
||||||
|
|
||||||
const tryResult =
|
|
||||||
this.discordVoiceService.tryJoinChannelAndEstablishVoiceConnection(
|
|
||||||
guildMember,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!tryResult.success) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Unable to process select result because the member was not in a voice channcel`,
|
|
||||||
);
|
|
||||||
const replyOptions = tryResult.reply as InteractionReplyOptions;
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: replyOptions.embeds,
|
|
||||||
content: undefined,
|
|
||||||
components: [],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bitrate = guildMember.voice.channel.bitrate;
|
|
||||||
|
|
||||||
const valueParts = interaction.values[0].split('_');
|
|
||||||
const type = valueParts[0];
|
|
||||||
const id = valueParts[1];
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'track':
|
|
||||||
const item = await this.jellyfinSearchService.getById(id);
|
|
||||||
const remoteImagesOfCurrentAlbum =
|
|
||||||
await this.jellyfinSearchService.getRemoteImageById(item.AlbumId);
|
|
||||||
const trackRemoteImage = chooseSuitableRemoteImage(
|
|
||||||
remoteImagesOfCurrentAlbum,
|
|
||||||
);
|
|
||||||
const addedIndex = this.enqueueSingleTrack(
|
|
||||||
item as BaseJellyfinAudioPlayable,
|
|
||||||
bitrate,
|
|
||||||
remoteImagesOfCurrentAlbum,
|
|
||||||
);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [
|
|
||||||
this.discordMessageService.buildMessage({
|
|
||||||
title: item.Name,
|
|
||||||
description: `Your track was added to the position ${addedIndex} in the playlist`,
|
|
||||||
mixin(embedBuilder) {
|
|
||||||
if (trackRemoteImage === undefined) {
|
|
||||||
return embedBuilder;
|
|
||||||
}
|
|
||||||
|
|
||||||
return embedBuilder.setThumbnail(trackRemoteImage.Url);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
components: [],
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'album':
|
|
||||||
const album = await this.jellyfinSearchService.getItemsByAlbum(id);
|
|
||||||
const remoteImages =
|
|
||||||
await this.jellyfinSearchService.getRemoteImageById(id);
|
|
||||||
const albumRemoteImage = chooseSuitableRemoteImage(remoteImages);
|
|
||||||
album.SearchHints.forEach((item) => {
|
|
||||||
this.enqueueSingleTrack(
|
|
||||||
item as BaseJellyfinAudioPlayable,
|
|
||||||
bitrate,
|
|
||||||
remoteImages,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [
|
|
||||||
this.discordMessageService.buildMessage({
|
|
||||||
title: `Added ${album.TotalRecordCount} items from your album`,
|
|
||||||
description: `${album.SearchHints.map((item) =>
|
|
||||||
trimStringToFixedLength(item.Name, 20),
|
|
||||||
).join(', ')}`,
|
|
||||||
mixin(embedBuilder) {
|
|
||||||
if (albumRemoteImage === undefined) {
|
|
||||||
return embedBuilder;
|
|
||||||
}
|
|
||||||
|
|
||||||
return embedBuilder.setThumbnail(albumRemoteImage.Url);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
components: [],
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'playlist':
|
|
||||||
const playlist = await this.jellyfinSearchService.getPlaylistById(id);
|
|
||||||
const addedRemoteImages: RemoteImageResult = {};
|
|
||||||
for (let index = 0; index < playlist.Items.length; index++) {
|
|
||||||
const item = playlist.Items[index];
|
|
||||||
const remoteImages =
|
|
||||||
await this.jellyfinSearchService.getRemoteImageById(id);
|
|
||||||
addedRemoteImages.Images.concat(remoteImages.Images);
|
|
||||||
this.enqueueSingleTrack(
|
|
||||||
item as BaseJellyfinAudioPlayable,
|
|
||||||
bitrate,
|
|
||||||
remoteImages,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const bestPlaylistRemoteImage =
|
|
||||||
chooseSuitableRemoteImage(addedRemoteImages);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [
|
|
||||||
this.discordMessageService.buildMessage({
|
|
||||||
title: `Added ${playlist.TotalRecordCount} items from your playlist`,
|
|
||||||
description: `${playlist.Items.map((item) =>
|
|
||||||
trimStringToFixedLength(item.Name, 20),
|
|
||||||
).join(', ')}`,
|
|
||||||
mixin(embedBuilder) {
|
|
||||||
if (bestPlaylistRemoteImage === undefined) {
|
|
||||||
return embedBuilder;
|
|
||||||
}
|
|
||||||
|
|
||||||
return embedBuilder.setThumbnail(bestPlaylistRemoteImage.Url);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
components: [],
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [
|
|
||||||
this.discordMessageService.buildErrorMessage({
|
|
||||||
title: 'Unable to process your selection',
|
|
||||||
description: `Sorry. I don't know the type you selected: \`\`${type}\`\`. Please report this bug to the developers.\n\nDebug Information: \`\`${interaction.values.join(
|
|
||||||
', ',
|
|
||||||
)}\`\``,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
components: [],
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enqueueSingleTrack(
|
|
||||||
jellyfinPlayable: BaseJellyfinAudioPlayable,
|
|
||||||
bitrate: number,
|
|
||||||
remoteImageResult: RemoteImageResult,
|
|
||||||
) {
|
|
||||||
const stream = this.jellyfinStreamBuilder.buildStreamUrl(
|
|
||||||
jellyfinPlayable.Id,
|
|
||||||
bitrate,
|
|
||||||
);
|
|
||||||
|
|
||||||
const milliseconds = jellyfinPlayable.RunTimeTicks / 10000;
|
|
||||||
|
|
||||||
return this.playbackService.enqueueTrack({
|
|
||||||
jellyfinId: jellyfinPlayable.Id,
|
|
||||||
name: jellyfinPlayable.Name,
|
|
||||||
durationInMilliseconds: milliseconds,
|
|
||||||
streamUrl: stream,
|
|
||||||
remoteImages: remoteImageResult,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
168
src/commands/play/play.comands.ts
Normal file
168
src/commands/play/play.comands.ts
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import { SlashCommandPipe } from '@discord-nestjs/common';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
Handler,
|
||||||
|
IA,
|
||||||
|
InteractionEvent,
|
||||||
|
On,
|
||||||
|
} from '@discord-nestjs/core';
|
||||||
|
|
||||||
|
import { RemoteImageInfo } from '@jellyfin/sdk/lib/generated-client/models';
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Logger } from '@nestjs/common/services';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CommandInteraction,
|
||||||
|
Events,
|
||||||
|
GuildMember,
|
||||||
|
Interaction,
|
||||||
|
InteractionReplyOptions,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
|
import { PlaybackService } from '../../playback/playback.service';
|
||||||
|
import { formatMillisecondsAsHumanReadable } from '../../utils/timeUtils';
|
||||||
|
import { DiscordMessageService } from '../../clients/discord/discord.message.service';
|
||||||
|
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';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
@Command({
|
||||||
|
name: 'play',
|
||||||
|
description: 'Search for an item on your Jellyfin instance',
|
||||||
|
})
|
||||||
|
export class PlayItemCommand {
|
||||||
|
private readonly logger: Logger = new Logger(PlayItemCommand.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly jellyfinSearchService: JellyfinSearchService,
|
||||||
|
private readonly discordMessageService: DiscordMessageService,
|
||||||
|
private readonly discordVoiceService: DiscordVoiceService,
|
||||||
|
private readonly playbackService: PlaybackService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Handler()
|
||||||
|
async handler(
|
||||||
|
@InteractionEvent(SlashCommandPipe) dto: PlayCommandParams,
|
||||||
|
@IA() interaction: CommandInteraction,
|
||||||
|
): Promise<InteractionReplyOptions | string> {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
|
const baseItems = PlayCommandParams.getBaseItemKinds(dto.type);
|
||||||
|
|
||||||
|
let item: SearchHint;
|
||||||
|
if (dto.name.startsWith('native-')) {
|
||||||
|
item = await this.jellyfinSearchService.getById(
|
||||||
|
dto.name.replace('native-', ''),
|
||||||
|
baseItems,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
item = (
|
||||||
|
await this.jellyfinSearchService.searchItem(dto.name, 1, baseItems)
|
||||||
|
).find((x) => x);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
await interaction.followUp({
|
||||||
|
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`,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 tracks = await item.toTracks(this.jellyfinSearchService);
|
||||||
|
const reducedDuration = tracks.reduce(
|
||||||
|
(sum, item) => sum + item.duration,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
this.playbackService.getPlaylistOrDefault().enqueueTracks(tracks);
|
||||||
|
|
||||||
|
const remoteImage: RemoteImageInfo | undefined = tracks
|
||||||
|
.flatMap((x) => x.getRemoteImages())
|
||||||
|
.find((x) => true);
|
||||||
|
|
||||||
|
await interaction.followUp({
|
||||||
|
embeds: [
|
||||||
|
this.discordMessageService.buildMessage({
|
||||||
|
title: `Added ${this.playbackService
|
||||||
|
.getPlaylistOrDefault()
|
||||||
|
.getLength()} tracks to your playlist (${formatMillisecondsAsHumanReadable(
|
||||||
|
reducedDuration,
|
||||||
|
)})`,
|
||||||
|
mixin(embedBuilder) {
|
||||||
|
if (!remoteImage) {
|
||||||
|
return embedBuilder;
|
||||||
|
}
|
||||||
|
return embedBuilder.setThumbnail(remoteImage.Url);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@On(Events.InteractionCreate)
|
||||||
|
async onAutocomplete(interaction: Interaction) {
|
||||||
|
if (!interaction.isAutocomplete()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusedAutoCompleteAction = interaction.options.getFocused(true);
|
||||||
|
const typeIndex: number | null = interaction.options.getInteger('type');
|
||||||
|
const type = Object.values(SearchType)[typeIndex] as SearchType;
|
||||||
|
const searchQuery = focusedAutoCompleteAction.value;
|
||||||
|
|
||||||
|
if (!searchQuery || searchQuery.length < 1) {
|
||||||
|
await interaction.respond([]);
|
||||||
|
this.logger.debug(
|
||||||
|
'Did not attempt a search, because the auto-complete option was empty',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Initiating auto-complete search for query '${searchQuery}' with type '${type}'`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hints = await this.jellyfinSearchService.searchItem(
|
||||||
|
searchQuery,
|
||||||
|
20,
|
||||||
|
PlayCommandParams.getBaseItemKinds(type),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hints.length === 0) {
|
||||||
|
await interaction.respond([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.respond(
|
||||||
|
hints.map((hint) => ({
|
||||||
|
name: hint.toString(),
|
||||||
|
value: `native-${hint.getId()}`,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
39
src/commands/play/play.params.ts.ts
Normal file
39
src/commands/play/play.params.ts.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { Choice, Param, ParamType } from '@discord-nestjs/core';
|
||||||
|
|
||||||
|
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models';
|
||||||
|
|
||||||
|
export enum SearchType {
|
||||||
|
Audio = 0,
|
||||||
|
AudioAlbum = 1,
|
||||||
|
Playlist = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlayCommandParams {
|
||||||
|
@Param({
|
||||||
|
required: true,
|
||||||
|
description: 'Item name on Jellyfin',
|
||||||
|
autocomplete: true,
|
||||||
|
})
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Choice(SearchType)
|
||||||
|
@Param({ description: 'Desired item type', type: ParamType.INTEGER })
|
||||||
|
type: SearchType | undefined;
|
||||||
|
|
||||||
|
static getBaseItemKinds(type: SearchType | undefined) {
|
||||||
|
switch (type) {
|
||||||
|
case SearchType.Audio:
|
||||||
|
return [BaseItemKind.Audio];
|
||||||
|
case SearchType.Playlist:
|
||||||
|
return [BaseItemKind.Playlist];
|
||||||
|
case SearchType.AudioAlbum:
|
||||||
|
return [BaseItemKind.MusicAlbum];
|
||||||
|
default:
|
||||||
|
return [
|
||||||
|
BaseItemKind.Audio,
|
||||||
|
BaseItemKind.Playlist,
|
||||||
|
BaseItemKind.MusicAlbum,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,88 +0,0 @@
|
|||||||
import { TransformPipe } from '@discord-nestjs/common';
|
|
||||||
|
|
||||||
import { Command, DiscordCommand, UsePipes } from '@discord-nestjs/core';
|
|
||||||
import { CommandInteraction } from 'discord.js';
|
|
||||||
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
|
||||||
import { PlaybackService } from '../playback/playback.service';
|
|
||||||
import { Constants } from '../utils/constants';
|
|
||||||
import { chooseSuitableRemoteImageFromTrack } from '../utils/remoteImages/remoteImages';
|
|
||||||
import { trimStringToFixedLength } from '../utils/stringUtils/stringUtils';
|
|
||||||
import { formatMillisecondsAsHumanReadable } from '../utils/timeUtils';
|
|
||||||
|
|
||||||
@Command({
|
|
||||||
name: 'playlist',
|
|
||||||
description: 'Print the current track information',
|
|
||||||
})
|
|
||||||
@UsePipes(TransformPipe)
|
|
||||||
export class PlaylistCommand implements DiscordCommand {
|
|
||||||
constructor(
|
|
||||||
private readonly discordMessageService: DiscordMessageService,
|
|
||||||
private readonly playbackService: PlaybackService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async handler(interaction: CommandInteraction): Promise<void> {
|
|
||||||
const playList = this.playbackService.getPlaylist();
|
|
||||||
|
|
||||||
if (playList.tracks.length === 0) {
|
|
||||||
await interaction.reply({
|
|
||||||
embeds: [
|
|
||||||
this.discordMessageService.buildMessage({
|
|
||||||
title: 'Your Playlist',
|
|
||||||
description:
|
|
||||||
'You do not have any tracks in your playlist.\nUse the play command to add new tracks to your playlist',
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const tracklist = playList.tracks
|
|
||||||
.slice(0, 10)
|
|
||||||
.map((track, index) => {
|
|
||||||
const isCurrent = track.id === playList.activeTrack;
|
|
||||||
|
|
||||||
let point = this.getListPoint(isCurrent, index);
|
|
||||||
point += `**${trimStringToFixedLength(track.track.name, 30)}**`;
|
|
||||||
|
|
||||||
if (isCurrent) {
|
|
||||||
point += ' :loud_sound:';
|
|
||||||
}
|
|
||||||
|
|
||||||
point += '\n';
|
|
||||||
point += Constants.Design.InvisibleSpace.repeat(2);
|
|
||||||
point += 'Duration: ';
|
|
||||||
point += formatMillisecondsAsHumanReadable(
|
|
||||||
track.track.durationInMilliseconds,
|
|
||||||
);
|
|
||||||
|
|
||||||
return point;
|
|
||||||
})
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
const activeTrack = this.playbackService.getActiveTrack();
|
|
||||||
const remoteImage = chooseSuitableRemoteImageFromTrack(activeTrack.track);
|
|
||||||
|
|
||||||
await interaction.reply({
|
|
||||||
embeds: [
|
|
||||||
this.discordMessageService.buildMessage({
|
|
||||||
title: 'Your Playlist',
|
|
||||||
description: `${tracklist}\n\nUse the /skip and /previous command to select a track`,
|
|
||||||
mixin(embedBuilder) {
|
|
||||||
if (remoteImage === undefined) {
|
|
||||||
return embedBuilder;
|
|
||||||
}
|
|
||||||
|
|
||||||
return embedBuilder.setThumbnail(remoteImage.Url);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private getListPoint(isCurrent: boolean, index: number) {
|
|
||||||
if (isCurrent) {
|
|
||||||
return `${index + 1}. `;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${index + 1}. `;
|
|
||||||
}
|
|
||||||
}
|
|
201
src/commands/playlist/playlist.command.ts
Normal file
201
src/commands/playlist/playlist.command.ts
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
import { CollectorInterceptor, SlashCommandPipe } from '@discord-nestjs/common';
|
||||||
|
import {
|
||||||
|
AppliedCollectors,
|
||||||
|
Command,
|
||||||
|
Handler,
|
||||||
|
IA,
|
||||||
|
InteractionEvent,
|
||||||
|
UseCollectors,
|
||||||
|
} from '@discord-nestjs/core';
|
||||||
|
|
||||||
|
import { Injectable, Logger, UseInterceptors } from '@nestjs/common';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonInteraction,
|
||||||
|
ButtonStyle,
|
||||||
|
CommandInteraction,
|
||||||
|
EmbedBuilder,
|
||||||
|
InteractionCollector,
|
||||||
|
InteractionReplyOptions,
|
||||||
|
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 { PlaylistInteractionCollector } from './playlist.interaction-collector';
|
||||||
|
import { PlaylistCommandParams } from './playlist.params';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
@Command({
|
||||||
|
name: 'playlist',
|
||||||
|
description: 'Print the current track information',
|
||||||
|
})
|
||||||
|
@UseInterceptors(CollectorInterceptor)
|
||||||
|
@UseCollectors(PlaylistInteractionCollector)
|
||||||
|
export class PlaylistCommand {
|
||||||
|
public pageData: Map<string, number> = new Map();
|
||||||
|
private readonly logger = new Logger(PlaylistCommand.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly discordMessageService: DiscordMessageService,
|
||||||
|
private readonly playbackService: PlaybackService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Handler()
|
||||||
|
async handler(
|
||||||
|
@InteractionEvent(SlashCommandPipe) dto: PlaylistCommandParams,
|
||||||
|
@IA() interaction: CommandInteraction,
|
||||||
|
@AppliedCollectors(0) collector: InteractionCollector<ButtonInteraction>,
|
||||||
|
): Promise<void> {
|
||||||
|
const page = dto.page ?? 0;
|
||||||
|
|
||||||
|
await interaction.reply(
|
||||||
|
this.getReplyForPage(page) as InteractionReplyOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.pageData.set(interaction.id, page);
|
||||||
|
this.logger.debug(
|
||||||
|
`Added '${interaction.id}' as a message id for page storage`,
|
||||||
|
);
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
this.logger.log(
|
||||||
|
`Removed the components of message from interaction '${interaction.id}' because the event collector has reachted the timeout`,
|
||||||
|
);
|
||||||
|
this.pageData.delete(interaction.id);
|
||||||
|
await interaction.editReply({
|
||||||
|
components: [],
|
||||||
|
});
|
||||||
|
}, 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getChunks() {
|
||||||
|
const playlist = this.playbackService.getPlaylistOrDefault();
|
||||||
|
return chunkArray(playlist.tracks, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getReplyForPage(
|
||||||
|
page: number,
|
||||||
|
): InteractionReplyOptions | InteractionUpdateOptions {
|
||||||
|
const chunks = this.getChunks();
|
||||||
|
|
||||||
|
if (chunks.length === 0) {
|
||||||
|
return {
|
||||||
|
embeds: [
|
||||||
|
this.discordMessageService.buildMessage({
|
||||||
|
title: 'There are no items in your playlist',
|
||||||
|
description:
|
||||||
|
'Use the ``/play`` command to add new items to your playlist',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
ephemeral: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page >= chunks.length) {
|
||||||
|
return {
|
||||||
|
embeds: [
|
||||||
|
this.discordMessageService.buildMessage({
|
||||||
|
title: 'Page does not exist',
|
||||||
|
description: 'Please pass a valid page',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
ephemeral: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentForPage = this.getContentForPage(chunks, page);
|
||||||
|
|
||||||
|
if (!contentForPage) {
|
||||||
|
return {
|
||||||
|
embeds: [
|
||||||
|
this.discordMessageService.buildMessage({
|
||||||
|
title: 'Your Playlist',
|
||||||
|
description:
|
||||||
|
'You do not have any tracks in your playlist.\nUse the ``/play`` command to add new tracks to your playlist',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
ephemeral: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPrevious = page;
|
||||||
|
const hasNext = page + 1 < chunks.length;
|
||||||
|
|
||||||
|
const rowBuilder = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setDisabled(!hasPrevious)
|
||||||
|
.setCustomId('playlist-controls-previous')
|
||||||
|
.setEmoji('◀️')
|
||||||
|
.setLabel('Previous')
|
||||||
|
.setStyle(ButtonStyle.Secondary),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setDisabled(!hasNext)
|
||||||
|
.setCustomId('playlist-controls-next')
|
||||||
|
.setEmoji('▶️')
|
||||||
|
.setLabel('Next')
|
||||||
|
.setStyle(ButtonStyle.Secondary),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
embeds: [contentForPage.toJSON()],
|
||||||
|
ephemeral: true,
|
||||||
|
components: [rowBuilder],
|
||||||
|
fetchReply: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getContentForPage(
|
||||||
|
chunks: Track[][],
|
||||||
|
page: number,
|
||||||
|
): EmbedBuilder | undefined {
|
||||||
|
this.logger.verbose(
|
||||||
|
`Received request for page ${page} of playlist page chunks`,
|
||||||
|
);
|
||||||
|
const playlist = this.playbackService.getPlaylistOrDefault();
|
||||||
|
|
||||||
|
if (page >= chunks.length || page < 0) {
|
||||||
|
this.logger.warn(`Request for page chunks was out of range: ${page}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = page * 10;
|
||||||
|
const chunk = chunks[page];
|
||||||
|
|
||||||
|
if (!chunk) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to extract chunk from playlist chunks array with page ${page}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)}**`;
|
||||||
|
|
||||||
|
if (isCurrent) {
|
||||||
|
point += ' :loud_sound:';
|
||||||
|
}
|
||||||
|
|
||||||
|
point += '\n';
|
||||||
|
point += Constants.Design.InvisibleSpace.repeat(2);
|
||||||
|
point += formatMillisecondsAsHumanReadable(track.getDuration());
|
||||||
|
|
||||||
|
return point;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return new EmbedBuilder().setTitle('Your playlist').setDescription(content);
|
||||||
|
}
|
||||||
|
}
|
88
src/commands/playlist/playlist.interaction-collector.ts
Normal file
88
src/commands/playlist/playlist.interaction-collector.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import {
|
||||||
|
Filter,
|
||||||
|
InjectCauseEvent,
|
||||||
|
InteractionEventCollector,
|
||||||
|
On,
|
||||||
|
} from '@discord-nestjs/core';
|
||||||
|
|
||||||
|
import { forwardRef, Inject, Injectable, Scope } from '@nestjs/common';
|
||||||
|
import { Logger } from '@nestjs/common/services';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ButtonInteraction,
|
||||||
|
ChatInputCommandInteraction,
|
||||||
|
InteractionUpdateOptions,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
|
import { PlaylistCommand } from './playlist.command';
|
||||||
|
|
||||||
|
@Injectable({ scope: Scope.REQUEST })
|
||||||
|
@InteractionEventCollector({ time: 60 * 1000 })
|
||||||
|
export class PlaylistInteractionCollector {
|
||||||
|
private readonly logger = new Logger(PlaylistInteractionCollector.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(forwardRef(() => PlaylistCommand))
|
||||||
|
private readonly playlistCommand: PlaylistCommand,
|
||||||
|
@InjectCauseEvent()
|
||||||
|
private readonly causeInteraction: ChatInputCommandInteraction,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Filter()
|
||||||
|
filter(interaction: ButtonInteraction): boolean {
|
||||||
|
return this.causeInteraction.id === interaction.message.interaction.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@On('collect')
|
||||||
|
async onCollect(interaction: ButtonInteraction): Promise<void> {
|
||||||
|
const targetPage = this.getInteraction(interaction);
|
||||||
|
this.logger.verbose(
|
||||||
|
`Extracted the target page ${targetPage} from the button interaction`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (targetPage === undefined) {
|
||||||
|
await interaction.update({
|
||||||
|
content: 'Unknown error',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Updating current page for interaction ${this.causeInteraction.id} to ${targetPage}`,
|
||||||
|
);
|
||||||
|
this.playlistCommand.pageData.set(this.causeInteraction.id, targetPage);
|
||||||
|
const reply = this.playlistCommand.getReplyForPage(targetPage);
|
||||||
|
await interaction.update(reply as InteractionUpdateOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getInteraction(interaction: ButtonInteraction): number | null {
|
||||||
|
const current = this.playlistCommand.pageData.get(this.causeInteraction.id);
|
||||||
|
|
||||||
|
if (current === undefined) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Unable to extract the current page from the cause interaction '${this.causeInteraction.id}'`,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Retrieved current page from command using id '${
|
||||||
|
this.causeInteraction.id
|
||||||
|
}' in list of ${
|
||||||
|
Object.keys(this.playlistCommand.pageData).length
|
||||||
|
}: ${current}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (interaction.customId) {
|
||||||
|
case 'playlist-controls-next':
|
||||||
|
return current + 1;
|
||||||
|
case 'playlist-controls-previous':
|
||||||
|
return current - 1;
|
||||||
|
default:
|
||||||
|
this.logger.error(
|
||||||
|
`Unable to map button interaction from collector to target page`,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
10
src/commands/playlist/playlist.params.ts
Normal file
10
src/commands/playlist/playlist.params.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Param, ParamType } from '@discord-nestjs/core';
|
||||||
|
|
||||||
|
export class PlaylistCommandParams {
|
||||||
|
@Param({
|
||||||
|
required: false,
|
||||||
|
description: 'The page',
|
||||||
|
type: ParamType.INTEGER,
|
||||||
|
})
|
||||||
|
page: number;
|
||||||
|
}
|
@ -1,23 +1,26 @@
|
|||||||
import { TransformPipe } from '@discord-nestjs/common';
|
import { Command, Handler, IA } from '@discord-nestjs/core';
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common/decorators';
|
||||||
|
|
||||||
import { Command, DiscordCommand, UsePipes } from '@discord-nestjs/core';
|
|
||||||
import { CommandInteraction } from 'discord.js';
|
import { CommandInteraction } from 'discord.js';
|
||||||
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
|
||||||
import { PlaybackService } from '../playback/playback.service';
|
|
||||||
|
|
||||||
|
import { PlaybackService } from '../playback/playback.service';
|
||||||
|
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
@Command({
|
@Command({
|
||||||
name: 'previous',
|
name: 'previous',
|
||||||
description: 'Go to the previous track',
|
description: 'Go to the previous track',
|
||||||
})
|
})
|
||||||
@UsePipes(TransformPipe)
|
export class PreviousTrackCommand {
|
||||||
export class PreviousTrackCommand implements DiscordCommand {
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly playbackService: PlaybackService,
|
private readonly playbackService: PlaybackService,
|
||||||
private readonly discordMessageService: DiscordMessageService,
|
private readonly discordMessageService: DiscordMessageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handler(interaction: CommandInteraction): Promise<void> {
|
@Handler()
|
||||||
if (!this.playbackService.previousTrack()) {
|
async handler(@IA() interaction: CommandInteraction): Promise<void> {
|
||||||
|
if (!this.playbackService.getPlaylistOrDefault().hasActiveTrack()) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
embeds: [
|
embeds: [
|
||||||
this.discordMessageService.buildErrorMessage({
|
this.discordMessageService.buildErrorMessage({
|
||||||
@ -25,8 +28,10 @@ export class PreviousTrackCommand implements DiscordCommand {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.playbackService.getPlaylistOrDefault().setPreviousTrackAsActiveTrack();
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
embeds: [
|
embeds: [
|
||||||
this.discordMessageService.buildMessage({
|
this.discordMessageService.buildMessage({
|
||||||
|
@ -1,26 +1,23 @@
|
|||||||
import { TransformPipe } from '@discord-nestjs/common';
|
import { Command, Handler, IA, InjectDiscordClient } from '@discord-nestjs/core';
|
||||||
|
|
||||||
|
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
DiscordCommand,
|
|
||||||
InjectDiscordClient,
|
|
||||||
UsePipes,
|
|
||||||
} from '@discord-nestjs/core';
|
|
||||||
import { Client, CommandInteraction, Status } from 'discord.js';
|
import { Client, CommandInteraction, Status } from 'discord.js';
|
||||||
|
|
||||||
import { formatDuration, intervalToDuration } from 'date-fns';
|
import { formatDuration, intervalToDuration } from 'date-fns';
|
||||||
|
|
||||||
|
import { Constants } from '../utils/constants';
|
||||||
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
||||||
import { JellyfinService } from '../clients/jellyfin/jellyfin.service';
|
import { JellyfinService } from '../clients/jellyfin/jellyfin.service';
|
||||||
import { Constants } from '../utils/constants';
|
|
||||||
|
|
||||||
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
|
|
||||||
|
|
||||||
@Command({
|
@Command({
|
||||||
name: 'status',
|
name: 'status',
|
||||||
description: 'Display the current status for troubleshooting',
|
description: 'Display the current status for troubleshooting',
|
||||||
})
|
})
|
||||||
@UsePipes(TransformPipe)
|
@Injectable()
|
||||||
export class StatusCommand implements DiscordCommand {
|
export class StatusCommand {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectDiscordClient()
|
@InjectDiscordClient()
|
||||||
private readonly client: Client,
|
private readonly client: Client,
|
||||||
@ -28,7 +25,8 @@ export class StatusCommand implements DiscordCommand {
|
|||||||
private readonly jellyfinService: JellyfinService,
|
private readonly jellyfinService: JellyfinService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handler(interaction: CommandInteraction): Promise<void> {
|
@Handler()
|
||||||
|
async handler(@IA() interaction: CommandInteraction): Promise<void> {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
embeds: [
|
embeds: [
|
||||||
this.discordMessageService.buildMessage({
|
this.discordMessageService.buildMessage({
|
||||||
|
@ -1,25 +1,28 @@
|
|||||||
import { TransformPipe } from '@discord-nestjs/common';
|
import { Command, Handler, IA } from '@discord-nestjs/core';
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { Command, DiscordCommand, UsePipes } from '@discord-nestjs/core';
|
|
||||||
import { CommandInteraction } from 'discord.js';
|
import { CommandInteraction } from 'discord.js';
|
||||||
|
|
||||||
|
import { PlaybackService } from '../playback/playback.service';
|
||||||
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
||||||
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
|
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
|
||||||
import { PlaybackService } from '../playback/playback.service';
|
|
||||||
|
|
||||||
@Command({
|
@Command({
|
||||||
name: 'stop',
|
name: 'stop',
|
||||||
description: 'Stop playback entirely and clear the current playlist',
|
description: 'Stop playback entirely and clear the current playlist',
|
||||||
})
|
})
|
||||||
@UsePipes(TransformPipe)
|
@Injectable()
|
||||||
export class StopPlaybackCommand implements DiscordCommand {
|
export class StopPlaybackCommand {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly playbackService: PlaybackService,
|
private readonly playbackService: PlaybackService,
|
||||||
private readonly discordMessageService: DiscordMessageService,
|
private readonly discordMessageService: DiscordMessageService,
|
||||||
private readonly discordVoiceService: DiscordVoiceService,
|
private readonly discordVoiceService: DiscordVoiceService,
|
||||||
) {}
|
) {}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
async handler(interaction: CommandInteraction): Promise<void> {
|
@Handler()
|
||||||
const hasActiveTrack = this.playbackService.hasActiveTrack();
|
async handler(@IA() interaction: CommandInteraction): Promise<void> {
|
||||||
|
const hasActiveTrack = this.playbackService.getPlaylistOrDefault();
|
||||||
const title = hasActiveTrack
|
const title = hasActiveTrack
|
||||||
? 'Playback stopped successfully'
|
? 'Playback stopped successfully'
|
||||||
: 'Playback failed to stop';
|
: 'Playback failed to stop';
|
||||||
@ -27,7 +30,7 @@ export class StopPlaybackCommand implements DiscordCommand {
|
|||||||
? '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.clear();
|
this.playbackService.getPlaylistOrDefault().clear();
|
||||||
this.discordVoiceService.stop(false);
|
this.discordVoiceService.stop(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,17 +1,18 @@
|
|||||||
import { TransformPipe } from '@discord-nestjs/common';
|
import { Command, Handler, IA } from '@discord-nestjs/core';
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
import { Command, DiscordCommand, UsePipes } from '@discord-nestjs/core';
|
|
||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { CommandInteraction, GuildMember } from 'discord.js';
|
import { CommandInteraction, GuildMember } from 'discord.js';
|
||||||
|
|
||||||
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
||||||
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
|
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
@Command({
|
@Command({
|
||||||
name: 'summon',
|
name: 'summon',
|
||||||
description: 'Join your current voice channel',
|
description: 'Join your current voice channel',
|
||||||
})
|
})
|
||||||
@UsePipes(TransformPipe)
|
export class SummonCommand {
|
||||||
export class SummonCommand implements DiscordCommand {
|
|
||||||
private readonly logger = new Logger(SummonCommand.name);
|
private readonly logger = new Logger(SummonCommand.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -19,7 +20,8 @@ export class SummonCommand implements DiscordCommand {
|
|||||||
private readonly discordMessageService: DiscordMessageService,
|
private readonly discordMessageService: DiscordMessageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handler(interaction: CommandInteraction): Promise<void> {
|
@Handler()
|
||||||
|
async handler(@IA() interaction: CommandInteraction): Promise<void> {
|
||||||
await interaction.deferReply();
|
await interaction.deferReply();
|
||||||
|
|
||||||
const guildMember = interaction.member as GuildMember;
|
const guildMember = interaction.member as GuildMember;
|
||||||
|
@ -5,8 +5,11 @@ import {
|
|||||||
} from '@nestjs/terminus';
|
} from '@nestjs/terminus';
|
||||||
import { HealthCheckExecutor } from '@nestjs/terminus/dist/health-check/health-check-executor.service';
|
import { HealthCheckExecutor } from '@nestjs/terminus/dist/health-check/health-check-executor.service';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
|
|
||||||
import { useDefaultMockerToken } from '../utils/tests/defaultMockerToken';
|
import { useDefaultMockerToken } from '../utils/tests/defaultMockerToken';
|
||||||
|
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
|
|
||||||
import { DiscordHealthIndicator } from './indicators/discord.indicator';
|
import { DiscordHealthIndicator } from './indicators/discord.indicator';
|
||||||
import { JellyfinHealthIndicator } from './indicators/jellyfin.indicator';
|
import { JellyfinHealthIndicator } from './indicators/jellyfin.indicator';
|
||||||
|
|
||||||
@ -39,7 +42,7 @@ describe('HealthController', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (token === HealthCheckService) {
|
if (token === HealthCheckService) {
|
||||||
return new HealthCheckService(new HealthCheckExecutor(), null);
|
return new HealthCheckService(new HealthCheckExecutor(), null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return useDefaultMockerToken(token);
|
return useDefaultMockerToken(token);
|
||||||
|
24
src/main.ts
24
src/main.ts
@ -1,8 +1,30 @@
|
|||||||
|
import { LogLevel } from '@nestjs/common/services';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
function getLoggingLevels(): LogLevel[] {
|
||||||
|
switch (process.env.LOG_LEVEL.toLowerCase()) {
|
||||||
|
case 'error':
|
||||||
|
return ['error'];
|
||||||
|
case 'warn':
|
||||||
|
return ['error', 'warn'];
|
||||||
|
case 'log':
|
||||||
|
return ['error', 'warn', 'log'];
|
||||||
|
case 'debug':
|
||||||
|
return ['error', 'warn', 'log', 'debug'];
|
||||||
|
case 'verbose':
|
||||||
|
return ['error', 'warn', 'log', 'debug', 'verbose'];
|
||||||
|
default:
|
||||||
|
console.log(`failed to process log level ${process.env.LOG_LEVEL}`);
|
||||||
|
return ['error', 'warn', 'log'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule, {
|
||||||
|
logger: getLoggingLevels(),
|
||||||
|
});
|
||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
await app.listen(process.env.PORT || 3000);
|
await app.listen(process.env.PORT || 3000);
|
||||||
}
|
}
|
||||||
|
@ -1,29 +1,18 @@
|
|||||||
import {
|
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from '@nestjs/common';
|
||||||
Catch,
|
|
||||||
DiscordArgumentMetadata,
|
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, CommandInteraction } from 'discord.js';
|
||||||
DiscordExceptionFilter,
|
|
||||||
} from '@discord-nestjs/core';
|
|
||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
ActionRowBuilder,
|
|
||||||
ButtonBuilder,
|
|
||||||
ButtonStyle,
|
|
||||||
CommandInteraction,
|
|
||||||
} from 'discord.js';
|
|
||||||
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
|
||||||
import { Constants } from '../utils/constants';
|
import { Constants } from '../utils/constants';
|
||||||
|
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
||||||
|
|
||||||
@Catch(Error)
|
@Catch(Error)
|
||||||
export class CommandExecutionError implements DiscordExceptionFilter {
|
export class CommandExecutionError implements ExceptionFilter {
|
||||||
private readonly logger = new Logger(CommandExecutionError.name);
|
private readonly logger = new Logger(CommandExecutionError.name);
|
||||||
|
|
||||||
constructor(private readonly discordMessageService: DiscordMessageService) {}
|
constructor(private readonly discordMessageService: DiscordMessageService) {}
|
||||||
|
|
||||||
async catch(
|
async catch(exception: Error, host: ArgumentsHost): Promise<void> {
|
||||||
exception: Error,
|
const interaction = host.getArgByIndex(0) as CommandInteraction;
|
||||||
metadata: DiscordArgumentMetadata<string, any>,
|
|
||||||
): Promise<void> {
|
|
||||||
const interaction: CommandInteraction = metadata.eventArgs[0];
|
|
||||||
|
|
||||||
if (!interaction.isCommand()) {
|
if (!interaction.isCommand()) {
|
||||||
return;
|
return;
|
||||||
@ -34,6 +23,10 @@ export class CommandExecutionError implements DiscordExceptionFilter {
|
|||||||
exception.stack,
|
exception.stack,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!interaction.isRepliable()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setLabel('Report this issue')
|
.setLabel('Report this issue')
|
||||||
|
@ -1,248 +0,0 @@
|
|||||||
import {
|
|
||||||
BaseItemKind,
|
|
||||||
SearchHint,
|
|
||||||
} from '@jellyfin/sdk/lib/generated-client/models';
|
|
||||||
import { JellyfinStreamBuilderService } from '../clients/jellyfin/jellyfin.stream.builder.service';
|
|
||||||
import { Track } from '../types/track';
|
|
||||||
import { trimStringToFixedLength } from '../utils/stringUtils/stringUtils';
|
|
||||||
|
|
||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { JellyfinSearchService } from '../clients/jellyfin/jellyfin.search.service';
|
|
||||||
|
|
||||||
export interface BaseJellyfinAudioPlayable {
|
|
||||||
/**
|
|
||||||
* The primary identifier of the item
|
|
||||||
*/
|
|
||||||
Id: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The name of the item
|
|
||||||
*/
|
|
||||||
Name: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The runtime in ticks. 10'000 ticks equal one second
|
|
||||||
*/
|
|
||||||
RunTimeTicks: number;
|
|
||||||
|
|
||||||
fromSearchHint(
|
|
||||||
jellyfinSearchService: JellyfinSearchService,
|
|
||||||
searchHint: SearchHint,
|
|
||||||
): Promise<BaseJellyfinAudioPlayable>;
|
|
||||||
|
|
||||||
fetchTracks(
|
|
||||||
jellyfinStreamBuilder: JellyfinStreamBuilderService,
|
|
||||||
bitrate: number,
|
|
||||||
): Track[];
|
|
||||||
|
|
||||||
prettyPrint(search: string): string;
|
|
||||||
|
|
||||||
getId(): string;
|
|
||||||
|
|
||||||
getValueId(): string;
|
|
||||||
|
|
||||||
getEmoji(): string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class JellyfinAudioItem implements BaseJellyfinAudioPlayable {
|
|
||||||
Id: string;
|
|
||||||
Name: string;
|
|
||||||
RunTimeTicks: number;
|
|
||||||
ItemId: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The year, when this was produced. Usually something like 2021
|
|
||||||
*/
|
|
||||||
ProductionYear?: number;
|
|
||||||
|
|
||||||
Album?: string;
|
|
||||||
|
|
||||||
AlbumId?: string;
|
|
||||||
|
|
||||||
AlbumArtist?: string;
|
|
||||||
|
|
||||||
Artists?: string[];
|
|
||||||
|
|
||||||
getValueId(): string {
|
|
||||||
return `track_${this.getId()}`;
|
|
||||||
}
|
|
||||||
async fromSearchHint(
|
|
||||||
jellyfinSearchService: JellyfinSearchService,
|
|
||||||
searchHint: SearchHint,
|
|
||||||
): Promise<BaseJellyfinAudioPlayable> {
|
|
||||||
this.Id = searchHint.Id;
|
|
||||||
this.ItemId = searchHint.ItemId;
|
|
||||||
this.Name = searchHint.Name;
|
|
||||||
this.RunTimeTicks = searchHint.RunTimeTicks;
|
|
||||||
this.Album = searchHint.Album;
|
|
||||||
this.AlbumArtist = searchHint.AlbumArtist;
|
|
||||||
this.AlbumId = searchHint.AlbumId;
|
|
||||||
this.Artists = searchHint.Artists;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
getEmoji(): string {
|
|
||||||
return '🎵';
|
|
||||||
}
|
|
||||||
|
|
||||||
getId(): string {
|
|
||||||
return this.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
prettyPrint(search: string): string {
|
|
||||||
let line = trimStringToFixedLength(
|
|
||||||
markSearchTermOverlap(this.Name, search),
|
|
||||||
30,
|
|
||||||
);
|
|
||||||
if (this.Artists !== undefined && this.Artists.length > 0) {
|
|
||||||
line += ` [${this.Artists.join(', ')}]`;
|
|
||||||
}
|
|
||||||
line += ` *(Audio)*`;
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchTracks(
|
|
||||||
jellyfinStreamBuilder: JellyfinStreamBuilderService,
|
|
||||||
bitrate: number,
|
|
||||||
): Track[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: this.Name,
|
|
||||||
durationInMilliseconds: this.RunTimeTicks / 1000,
|
|
||||||
jellyfinId: this.Id,
|
|
||||||
streamUrl: jellyfinStreamBuilder.buildStreamUrl(this.Id, bitrate),
|
|
||||||
remoteImages: {},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class JellyfinAudioPlaylist implements BaseJellyfinAudioPlayable {
|
|
||||||
getValueId(): string {
|
|
||||||
return `playlist_${this.getId()}`;
|
|
||||||
}
|
|
||||||
async fromSearchHint(
|
|
||||||
jellyfinSearchService: JellyfinSearchService,
|
|
||||||
searchHint: SearchHint,
|
|
||||||
): Promise<BaseJellyfinAudioPlayable> {
|
|
||||||
this.Id = searchHint.Id;
|
|
||||||
this.Name = searchHint.Name;
|
|
||||||
this.RunTimeTicks = searchHint.RunTimeTicks;
|
|
||||||
const playlist = await jellyfinSearchService.getPlaylistById(searchHint.Id);
|
|
||||||
this.Items = playlist.Items;
|
|
||||||
this.TotalRecordCount = playlist.TotalRecordCount;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
getEmoji(): string {
|
|
||||||
return '📚';
|
|
||||||
}
|
|
||||||
|
|
||||||
getId(): string {
|
|
||||||
return this.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
prettyPrint(search: string): string {
|
|
||||||
return `${markSearchTermOverlap(this.Name, search)} (${
|
|
||||||
this.TotalRecordCount
|
|
||||||
} items) (Playlist)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchTracks(
|
|
||||||
jellyfinStreamBuilder: JellyfinStreamBuilderService,
|
|
||||||
bitrate: number,
|
|
||||||
): Track[] {
|
|
||||||
return this.Items.flatMap((item) =>
|
|
||||||
item.fetchTracks(jellyfinStreamBuilder, bitrate),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Id: string;
|
|
||||||
Name: string;
|
|
||||||
RunTimeTicks: number;
|
|
||||||
Items: JellyfinAudioItem[];
|
|
||||||
TotalRecordCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class JellyfinMusicAlbum implements BaseJellyfinAudioPlayable {
|
|
||||||
Id: string;
|
|
||||||
Name: string;
|
|
||||||
RunTimeTicks: number;
|
|
||||||
SearchHints: JellyfinAudioItem[];
|
|
||||||
TotalRecordCount: number;
|
|
||||||
|
|
||||||
async fromSearchHint(
|
|
||||||
jellyfinSearchService: JellyfinSearchService,
|
|
||||||
searchHint: SearchHint,
|
|
||||||
): Promise<JellyfinMusicAlbum> {
|
|
||||||
this.Id = searchHint.Id;
|
|
||||||
this.Name = searchHint.Name;
|
|
||||||
this.RunTimeTicks = searchHint.RunTimeTicks;
|
|
||||||
const album = await jellyfinSearchService.getItemsByAlbum(searchHint.Id);
|
|
||||||
this.SearchHints = album.SearchHints;
|
|
||||||
this.TotalRecordCount = album.TotalRecordCount;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
fetchTracks(
|
|
||||||
jellyfinStreamBuilder: JellyfinStreamBuilderService,
|
|
||||||
bitrate: number,
|
|
||||||
): Track[] {
|
|
||||||
return this.SearchHints.flatMap((item) =>
|
|
||||||
item.fetchTracks(jellyfinStreamBuilder, bitrate),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
prettyPrint(search: string): string {
|
|
||||||
return `${markSearchTermOverlap(this.Name, search)} (${
|
|
||||||
this.TotalRecordCount
|
|
||||||
} items) (Album)`;
|
|
||||||
}
|
|
||||||
getId(): string {
|
|
||||||
return this.Id;
|
|
||||||
}
|
|
||||||
getValueId(): string {
|
|
||||||
return `album_${this.getId()}`;
|
|
||||||
}
|
|
||||||
getEmoji(): string {
|
|
||||||
return '📀';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const searchResultAsJellyfinAudio = async (
|
|
||||||
logger: Logger,
|
|
||||||
jellyfinSearchService: JellyfinSearchService,
|
|
||||||
searchHint: SearchHint,
|
|
||||||
) => {
|
|
||||||
switch (searchHint.Type) {
|
|
||||||
case BaseItemKind[BaseItemKind.Audio]:
|
|
||||||
return await new JellyfinAudioItem().fromSearchHint(
|
|
||||||
jellyfinSearchService,
|
|
||||||
searchHint,
|
|
||||||
);
|
|
||||||
case BaseItemKind[BaseItemKind.Playlist]:
|
|
||||||
return await new JellyfinAudioPlaylist().fromSearchHint(
|
|
||||||
jellyfinSearchService,
|
|
||||||
searchHint,
|
|
||||||
);
|
|
||||||
case BaseItemKind[BaseItemKind.MusicAlbum]:
|
|
||||||
return await new JellyfinMusicAlbum().fromSearchHint(
|
|
||||||
jellyfinSearchService,
|
|
||||||
searchHint,
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
logger.error(
|
|
||||||
`Failed to parse Jellyfin response for item type ${searchHint.Type}`,
|
|
||||||
);
|
|
||||||
null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const markSearchTermOverlap = (value: string, searchTerm: string) => {
|
|
||||||
const startIndex = value.indexOf(searchTerm);
|
|
||||||
const actualValue = value.substring(
|
|
||||||
startIndex,
|
|
||||||
startIndex + 1 + searchTerm.length,
|
|
||||||
);
|
|
||||||
return `${value.substring(0, startIndex)}**${actualValue}**${value.substring(
|
|
||||||
startIndex + 1 + actualValue.length,
|
|
||||||
)}`;
|
|
||||||
};
|
|
26
src/models/search/AlbumSearchHint.ts
Normal file
26
src/models/search/AlbumSearchHint.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { SearchHint as JellyfinSearchHint } from '@jellyfin/sdk/lib/generated-client/models';
|
||||||
|
|
||||||
|
import { Track } from '../shared/Track';
|
||||||
|
import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service';
|
||||||
|
|
||||||
|
import { SearchHint } from './SearchHint';
|
||||||
|
|
||||||
|
export class AlbumSearchHint extends SearchHint {
|
||||||
|
override toString(): string {
|
||||||
|
return `🎶 ${this.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static constructFromHint(hint: JellyfinSearchHint) {
|
||||||
|
return new AlbumSearchHint(hint.Id, hint.Name, hint.RunTimeTicks / 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
override async toTracks(
|
||||||
|
searchService: JellyfinSearchService,
|
||||||
|
): Promise<Track[]> {
|
||||||
|
const albumItems = await searchService.getAlbumItems(this.id);
|
||||||
|
const tracks = albumItems.map(async (x) =>
|
||||||
|
(await x.toTracks(searchService)).find((x) => x !== null),
|
||||||
|
);
|
||||||
|
return await Promise.all(tracks);
|
||||||
|
}
|
||||||
|
}
|
30
src/models/search/PlaylistSearchHint.ts
Normal file
30
src/models/search/PlaylistSearchHint.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { SearchHint as JellyfinSearchHint } from '@jellyfin/sdk/lib/generated-client/models';
|
||||||
|
|
||||||
|
import { Track } from '../shared/Track';
|
||||||
|
import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service';
|
||||||
|
|
||||||
|
import { SearchHint } from './SearchHint';
|
||||||
|
|
||||||
|
export class PlaylistSearchHint extends SearchHint {
|
||||||
|
override toString(): string {
|
||||||
|
return `🎧 ${this.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static constructFromHint(hint: JellyfinSearchHint) {
|
||||||
|
return new PlaylistSearchHint(
|
||||||
|
hint.Id,
|
||||||
|
hint.Name,
|
||||||
|
hint.RunTimeTicks / 10000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
override async toTracks(
|
||||||
|
searchService: JellyfinSearchService,
|
||||||
|
): Promise<Track[]> {
|
||||||
|
const playlistItems = await searchService.getPlaylistitems(this.id);
|
||||||
|
const tracks = playlistItems.map(async (x) =>
|
||||||
|
(await x.toTracks(searchService)).find((x) => x !== null),
|
||||||
|
);
|
||||||
|
return await Promise.all(tracks);
|
||||||
|
}
|
||||||
|
}
|
31
src/models/search/SearchHint.ts
Normal file
31
src/models/search/SearchHint.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { SearchHint as JellyfinSearchHint } from '@jellyfin/sdk/lib/generated-client/models';
|
||||||
|
|
||||||
|
import { Track } from '../shared/Track';
|
||||||
|
import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service';
|
||||||
|
|
||||||
|
export class SearchHint {
|
||||||
|
constructor(
|
||||||
|
protected readonly id: string,
|
||||||
|
protected readonly name: string,
|
||||||
|
protected runtimeInMilliseconds: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
return `🎵 ${this.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTracks(searchService: JellyfinSearchService): Promise<Track[]> {
|
||||||
|
const remoteImages = await searchService.getRemoteImageById(this.id);
|
||||||
|
return [
|
||||||
|
new Track(this.id, this.name, this.runtimeInMilliseconds, remoteImages),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
getId(): string {
|
||||||
|
return this.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
static constructFromHint(hint: JellyfinSearchHint) {
|
||||||
|
return new SearchHint(hint.Id, hint.Name, hint.RunTimeTicks / 10000);
|
||||||
|
}
|
||||||
|
}
|
142
src/models/shared/Playlist.ts
Normal file
142
src/models/shared/Playlist.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
|
||||||
|
import { Track } from './Track';
|
||||||
|
|
||||||
|
export class Playlist {
|
||||||
|
tracks: Track[];
|
||||||
|
activeTrackIndex?: number;
|
||||||
|
|
||||||
|
constructor(private readonly eventEmitter: EventEmitter2) {
|
||||||
|
this.tracks = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns if the playlist has been started.
|
||||||
|
* Does not indicate if it's paused.
|
||||||
|
* @returns if the playlist has been started and has an active track
|
||||||
|
*/
|
||||||
|
hasStarted() {
|
||||||
|
return this.activeTrackIndex !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the active track is out of bounds
|
||||||
|
* @returns active track or undefined if there's none
|
||||||
|
*/
|
||||||
|
getActiveTrack(): Track | undefined {
|
||||||
|
if (this.isActiveTrackOutOfSync()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return this.tracks[this.activeTrackIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
isEmpty(): boolean {
|
||||||
|
return this.tracks.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasActiveTrack(): boolean {
|
||||||
|
return (
|
||||||
|
this.activeTrackIndex !== undefined && !this.isActiveTrackOutOfSync()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLength() {
|
||||||
|
return this.tracks.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go to the next track in the playlist
|
||||||
|
* @returns if the track has been changed successfully
|
||||||
|
*/
|
||||||
|
setNextTrackAsActiveTrack(): boolean {
|
||||||
|
if (this.activeTrackIndex >= this.tracks.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeTrackIndex++;
|
||||||
|
this.eventEmitter.emit('controls.playlist.tracks.next', {
|
||||||
|
newActive: this.activeTrackIndex,
|
||||||
|
});
|
||||||
|
this.announceTrackChange();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go to the previous track in the playlist
|
||||||
|
* @returns if the track has been changed successfully
|
||||||
|
*/
|
||||||
|
setPreviousTrackAsActiveTrack(): boolean {
|
||||||
|
if (this.activeTrackIndex <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeTrackIndex--;
|
||||||
|
this.eventEmitter.emit('controls.playlist.tracks.previous', {
|
||||||
|
newActive: this.activeTrackIndex,
|
||||||
|
});
|
||||||
|
this.announceTrackChange();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add new track(-s) to the playlist
|
||||||
|
* @param tracks the tracks that should be added
|
||||||
|
* @returns the new lendth of the tracks in the playlist
|
||||||
|
*/
|
||||||
|
enqueueTracks(tracks: Track[]) {
|
||||||
|
this.eventEmitter.emit('controls.playlist.tracks.enqueued', {
|
||||||
|
count: tracks.length,
|
||||||
|
activeTrack: this.activeTrackIndex,
|
||||||
|
});
|
||||||
|
const length = this.tracks.push(...tracks);
|
||||||
|
|
||||||
|
// emit a track change if there is no item
|
||||||
|
if (this.activeTrackIndex === undefined) {
|
||||||
|
this.announceTrackChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if there is a next track
|
||||||
|
* @returns if there is a track next in the playlist
|
||||||
|
*/
|
||||||
|
hasNextTrackInPlaylist() {
|
||||||
|
return this.activeTrackIndex + 1 < this.tracks.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if there is a previous track
|
||||||
|
* @returns if there is a previous track in the playlist
|
||||||
|
*/
|
||||||
|
hasPreviousTrackInPlaylist() {
|
||||||
|
return this.activeTrackIndex > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.eventEmitter.emit('controls.playlist.tracks.clear');
|
||||||
|
this.tracks = [];
|
||||||
|
this.activeTrackIndex = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private announceTrackChange() {
|
||||||
|
if (!this.activeTrackIndex) {
|
||||||
|
this.activeTrackIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.eventEmitter.emit('internal.audio.announce', this.getActiveTrack());
|
||||||
|
}
|
||||||
|
|
||||||
|
private isActiveTrackOutOfSync(): boolean {
|
||||||
|
return (
|
||||||
|
this.activeTrackIndex < 0 || this.activeTrackIndex >= this.tracks.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlaylistPlaybackType =
|
||||||
|
| 'once'
|
||||||
|
| 'repeat-once'
|
||||||
|
| 'repeat-indefinetly'
|
||||||
|
| 'shuffle';
|
53
src/models/shared/Track.ts
Normal file
53
src/models/shared/Track.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
RemoteImageInfo,
|
||||||
|
RemoteImageResult,
|
||||||
|
} from '@jellyfin/sdk/lib/generated-client/models';
|
||||||
|
|
||||||
|
import { JellyfinStreamBuilderService } from '../../clients/jellyfin/jellyfin.stream.builder.service';
|
||||||
|
|
||||||
|
export class Track {
|
||||||
|
/**
|
||||||
|
* The identifier of this track, structured as a UID.
|
||||||
|
* This id can be used to build a stream url and send more API requests to Jellyfin
|
||||||
|
*/
|
||||||
|
readonly id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the track
|
||||||
|
*/
|
||||||
|
readonly name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The duration of the track
|
||||||
|
*/
|
||||||
|
readonly duration: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A result object that contains a collection of images that are available outside the current network.
|
||||||
|
*/
|
||||||
|
readonly remoteImages?: RemoteImageResult;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
duration: number,
|
||||||
|
remoteImages?: RemoteImageResult,
|
||||||
|
) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
this.duration = duration;
|
||||||
|
this.remoteImages = remoteImages;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDuration() {
|
||||||
|
return this.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStreamUrl(streamBuilder: JellyfinStreamBuilderService) {
|
||||||
|
return streamBuilder.buildStreamUrl(this.id, 96000);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRemoteImages(): RemoteImageInfo[] {
|
||||||
|
return this.remoteImages.Images;
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +0,0 @@
|
|||||||
import { Param } from '@discord-nestjs/core';
|
|
||||||
|
|
||||||
export class TrackRequestDto {
|
|
||||||
@Param({ required: true, description: 'Track name to search' })
|
|
||||||
search: string;
|
|
||||||
}
|
|
@ -1,143 +1,21 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Playlist } from '../types/playlist';
|
|
||||||
import { Track } from '../types/track';
|
|
||||||
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
|
||||||
|
import { Playlist } from '../models/shared/Playlist';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PlaybackService {
|
export class PlaybackService {
|
||||||
private readonly logger = new Logger(PlaybackService.name);
|
private readonly logger = new Logger(PlaybackService.name);
|
||||||
|
private playlist: Playlist | undefined = undefined;
|
||||||
private readonly playlist: Playlist = {
|
|
||||||
tracks: [],
|
|
||||||
activeTrack: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(private readonly eventEmitter: EventEmitter2) {}
|
constructor(private readonly eventEmitter: EventEmitter2) {}
|
||||||
|
|
||||||
getActiveTrack() {
|
getPlaylistOrDefault(): Playlist {
|
||||||
return this.getTrackById(this.playlist.activeTrack);
|
if (this.playlist) {
|
||||||
}
|
|
||||||
|
|
||||||
setActiveTrack(trackId: string) {
|
|
||||||
const track = this.getTrackById(trackId);
|
|
||||||
|
|
||||||
if (!track) {
|
|
||||||
throw Error('track is not in playlist');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.playlist.activeTrack = track.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
nextTrack() {
|
|
||||||
const keys = this.getTrackIds();
|
|
||||||
const index = this.getActiveIndex();
|
|
||||||
|
|
||||||
if (!this.hasActiveTrack() || index + 1 >= keys.length) {
|
|
||||||
this.logger.debug(
|
|
||||||
`Unable to go to next track, because playback has reached end of the playlist`,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newKey = keys[index + 1];
|
|
||||||
this.setActiveTrack(newKey);
|
|
||||||
this.getActiveTrackAndEmitEvent();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
previousTrack() {
|
|
||||||
const index = this.getActiveIndex();
|
|
||||||
|
|
||||||
if (!this.hasActiveTrack() || index < 1) {
|
|
||||||
this.logger.debug(
|
|
||||||
`Unable to go to previous track, because there is no previous track in the playlist`,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys = this.getTrackIds();
|
|
||||||
const newKey = keys[index - 1];
|
|
||||||
this.setActiveTrack(newKey);
|
|
||||||
this.getActiveTrackAndEmitEvent();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
enqueueTrack(track: Track) {
|
|
||||||
const uuid = uuidv4();
|
|
||||||
|
|
||||||
const emptyBefore = this.playlist.tracks.length === 0;
|
|
||||||
|
|
||||||
this.playlist.tracks.push({
|
|
||||||
id: uuid,
|
|
||||||
track: track,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.debug(
|
|
||||||
`Added the track '${track.jellyfinId}' to the current playlist`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (emptyBefore) {
|
|
||||||
this.setActiveTrack(this.playlist.tracks.find((x) => x.id === uuid).id);
|
|
||||||
this.getActiveTrackAndEmitEvent();
|
|
||||||
}
|
|
||||||
|
|
||||||
return uuid;
|
|
||||||
}
|
|
||||||
|
|
||||||
enqueTrackAndInstantyPlay(track: Track) {
|
|
||||||
const uuid = uuidv4();
|
|
||||||
|
|
||||||
this.playlist.tracks.push({
|
|
||||||
id: uuid,
|
|
||||||
track: track,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.setActiveTrack(uuid);
|
|
||||||
this.getActiveTrackAndEmitEvent();
|
|
||||||
}
|
|
||||||
|
|
||||||
set(tracks: Track[]) {
|
|
||||||
this.playlist.tracks = tracks.map((t) => ({
|
|
||||||
id: uuidv4(),
|
|
||||||
track: t,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
this.playlist.tracks = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
hasNextTrack() {
|
|
||||||
return this.getActiveIndex() + 1 < this.getTrackIds().length;
|
|
||||||
}
|
|
||||||
|
|
||||||
hasActiveTrack() {
|
|
||||||
return this.playlist.activeTrack !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
getPlaylist(): Playlist {
|
|
||||||
return this.playlist;
|
return this.playlist;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getTrackById(id: string) {
|
this.playlist = new Playlist(this.eventEmitter);
|
||||||
return this.playlist.tracks.find((x) => x.id === id);
|
return this.playlist;
|
||||||
}
|
|
||||||
|
|
||||||
private getTrackIds() {
|
|
||||||
return this.playlist.tracks.map((item) => item.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getActiveIndex() {
|
|
||||||
return this.getTrackIds().indexOf(this.playlist.activeTrack);
|
|
||||||
}
|
|
||||||
|
|
||||||
getActiveTrackAndEmitEvent() {
|
|
||||||
const activeTrack = this.getActiveTrack();
|
|
||||||
this.logger.debug(
|
|
||||||
`A new track (${activeTrack.id}) was requested and will be emmitted as an event`,
|
|
||||||
);
|
|
||||||
this.eventEmitter.emit('playback.newTrack', activeTrack.track);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
4
src/utils/arrayUtils.ts
Normal file
4
src/utils/arrayUtils.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export const chunkArray = <T>(a: T[], size): T[][] =>
|
||||||
|
Array.from(new Array(Math.ceil(a.length / size)), (_, i) =>
|
||||||
|
a.slice(i * size, i * size + size),
|
||||||
|
);
|
@ -3,7 +3,7 @@ export const Constants = {
|
|||||||
Version: {
|
Version: {
|
||||||
Major: 0,
|
Major: 0,
|
||||||
Minor: 0,
|
Minor: 0,
|
||||||
Patch: 4,
|
Patch: 5,
|
||||||
All: () =>
|
All: () =>
|
||||||
`${Constants.Metadata.Version.Major}.${Constants.Metadata.Version.Minor}.${Constants.Metadata.Version.Patch}`,
|
`${Constants.Metadata.Version.Major}.${Constants.Metadata.Version.Minor}.${Constants.Metadata.Version.Patch}`,
|
||||||
},
|
},
|
||||||
@ -33,11 +33,11 @@ export const Constants = {
|
|||||||
InvisibleSpace: '\u1CBC',
|
InvisibleSpace: '\u1CBC',
|
||||||
Icons: {
|
Icons: {
|
||||||
JellyfinLogo:
|
JellyfinLogo:
|
||||||
'https://github.com/manuel-rw/jellyfin-discord-music-bot/blob/nestjs-migration/images/icons/jellyfin-icon-squared.png?raw=true',
|
'https://raw.githubusercontent.com/manuel-rw/jellyfin-discord-music-bot/master/images/icons/jellyfin-icon-squared.png',
|
||||||
SuccessIcon:
|
SuccessIcon:
|
||||||
'https://github.com/manuel-rw/jellyfin-discord-music-bot/blob/nestjs-migration/images/icons/circle-check.png?raw=true',
|
'https://raw.githubusercontent.com/manuel-rw/jellyfin-discord-music-bot/master/images/icons/circle-check.png',
|
||||||
ErrorIcon:
|
ErrorIcon:
|
||||||
'https://github.com/manuel-rw/jellyfin-discord-music-bot/blob/nestjs-migration/images/icons/alert-circle.png?raw=true',
|
'https://raw.githubusercontent.com/manuel-rw/jellyfin-discord-music-bot/master/images/icons/alert-circle.png',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -1,29 +0,0 @@
|
|||||||
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models';
|
|
||||||
import { chooseSuitableRemoteImageFromTrack } from './remoteImages';
|
|
||||||
|
|
||||||
describe('remoteImages', () => {
|
|
||||||
it('chooseSuitableRemoteImageFromTrack', () => {
|
|
||||||
const remoteImage = chooseSuitableRemoteImageFromTrack({
|
|
||||||
name: 'Testing Music',
|
|
||||||
durationInMilliseconds: 6969,
|
|
||||||
jellyfinId: '7384783',
|
|
||||||
remoteImages: {
|
|
||||||
Images: [
|
|
||||||
{
|
|
||||||
Type: ImageType.Primary,
|
|
||||||
Url: 'nice picture.png',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Type: ImageType.Screenshot,
|
|
||||||
Url: 'not nice picture',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
streamUrl: 'http://jellyfin/example-stream',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(remoteImage).not.toBeNull();
|
|
||||||
expect(remoteImage.Type).toBe(ImageType.Primary);
|
|
||||||
expect(remoteImage.Url).toBe('nice picture.png');
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,25 +0,0 @@
|
|||||||
import {
|
|
||||||
ImageType,
|
|
||||||
RemoteImageInfo,
|
|
||||||
RemoteImageResult,
|
|
||||||
} from '@jellyfin/sdk/lib/generated-client/models';
|
|
||||||
import { Track } from '../../types/track';
|
|
||||||
|
|
||||||
export const chooseSuitableRemoteImage = (
|
|
||||||
remoteImageResult: RemoteImageResult,
|
|
||||||
): RemoteImageInfo | undefined => {
|
|
||||||
const primaryImages: RemoteImageInfo[] | undefined =
|
|
||||||
remoteImageResult.Images.filter((x) => x.Type === ImageType.Primary);
|
|
||||||
|
|
||||||
if (primaryImages.length > 0) {
|
|
||||||
return primaryImages[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (remoteImageResult.Images.length > 0) {
|
|
||||||
return remoteImageResult.Images[0];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const chooseSuitableRemoteImageFromTrack = (track: Track) => {
|
|
||||||
return chooseSuitableRemoteImage(track.remoteImages);
|
|
||||||
};
|
|
@ -1,11 +1,17 @@
|
|||||||
import { formatDuration, intervalToDuration } from 'date-fns';
|
import { formatDuration, intervalToDuration } from 'date-fns';
|
||||||
|
|
||||||
export const formatMillisecondsAsHumanReadable = (milliseconds: number) => {
|
export const formatMillisecondsAsHumanReadable = (
|
||||||
|
milliseconds: number,
|
||||||
|
format = ['years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds'],
|
||||||
|
) => {
|
||||||
const duration = formatDuration(
|
const duration = formatDuration(
|
||||||
intervalToDuration({
|
intervalToDuration({
|
||||||
start: milliseconds,
|
start: milliseconds,
|
||||||
end: 0,
|
end: 0,
|
||||||
}),
|
}),
|
||||||
|
{
|
||||||
|
format: format,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return duration;
|
return duration;
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user