mirror of
https://github.com/informaticker/discord-jellyfin-bot.git
synced 2024-11-25 02:51:57 +01:00
✨ Add time tracking
This commit is contained in:
parent
831e03a77f
commit
d1fc61c6fe
@ -43,7 +43,8 @@
|
||||
"rimraf": "^4.4.1",
|
||||
"rxjs": "^7.2.0",
|
||||
"uuid": "^9.0.0",
|
||||
"ws": "^8.13.0"
|
||||
"ws": "^8.13.0",
|
||||
"zod": "^3.21.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^9.3.0",
|
||||
|
@ -14,6 +14,7 @@ import {
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Logger } from '@nestjs/common/services';
|
||||
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
|
||||
import { GuildMember } from 'discord.js';
|
||||
|
||||
@ -224,7 +225,7 @@ export class DiscordVoiceService {
|
||||
|
||||
if (this.audioPlayer === undefined) {
|
||||
this.logger.debug(
|
||||
"Initialized new instance of AudioPlayer because it has not been defined yet",
|
||||
'Initialized new instance of AudioPlayer because it has not been defined yet',
|
||||
);
|
||||
this.audioPlayer = createAudioPlayer({
|
||||
debug: process.env.DEBUG?.toLowerCase() === 'true',
|
||||
@ -291,7 +292,7 @@ export class DiscordVoiceService {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug("Audio player finished playing old resource");
|
||||
this.logger.debug('Audio player finished playing old resource');
|
||||
|
||||
const playlist = this.playbackService.getPlaylistOrDefault();
|
||||
const finishedTrack = playlist.getActiveTrack();
|
||||
@ -308,11 +309,43 @@ export class DiscordVoiceService {
|
||||
);
|
||||
|
||||
if (!hasNextTrack) {
|
||||
this.logger.debug("Reached the end of the playlist");
|
||||
this.logger.debug('Reached the end of the playlist');
|
||||
return;
|
||||
}
|
||||
|
||||
this.playbackService.getPlaylistOrDefault().setNextTrackAsActiveTrack();
|
||||
});
|
||||
}
|
||||
|
||||
@Interval(500)
|
||||
private checkAudioResourcePlayback() {
|
||||
if (!this.audioResource) {
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = this.audioResource.playbackDuration;
|
||||
|
||||
const playlist = this.playbackService.getPlaylistOrDefault();
|
||||
|
||||
if (!playlist) {
|
||||
this.logger.error(
|
||||
`Failed to update ellapsed audio time because playlist was unexpectitly undefined`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeTrack = playlist.getActiveTrack();
|
||||
|
||||
if (!activeTrack) {
|
||||
this.logger.error(
|
||||
`Failed to update ellapsed audio time because active track was unexpectitly undefined`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
activeTrack.updatePlaybackProgress(progress);
|
||||
this.logger.verbose(
|
||||
`Reporting progress: ${progress} on track ${activeTrack.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api';
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { Track } from '../../models/shared/Track';
|
||||
|
||||
import { PlaybackService } from '../../playback/playback.service';
|
||||
@ -52,6 +53,7 @@ export class JellyinPlaystateService {
|
||||
await this.playstateApi.reportPlaybackStart({
|
||||
playbackStartInfo: {
|
||||
ItemId: track.id,
|
||||
PositionTicks: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -60,7 +62,7 @@ export class JellyinPlaystateService {
|
||||
private async onPlaybackFinished(track: Track) {
|
||||
if (!track) {
|
||||
this.logger.error(
|
||||
"Unable to report playback because finished track was undefined",
|
||||
'Unable to report playback because finished track was undefined',
|
||||
);
|
||||
return;
|
||||
}
|
||||
@ -68,6 +70,7 @@ export class JellyinPlaystateService {
|
||||
await this.playstateApi.reportPlaybackStopped({
|
||||
playbackStopInfo: {
|
||||
ItemId: track.id,
|
||||
PositionTicks: track.playbackProgress * 10000,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -78,7 +81,7 @@ export class JellyinPlaystateService {
|
||||
|
||||
if (!track) {
|
||||
this.logger.error(
|
||||
"Unable to report changed playstate to Jellyfin because no track was active",
|
||||
'Unable to report changed playstate to Jellyfin because no track was active',
|
||||
);
|
||||
return;
|
||||
}
|
||||
@ -87,7 +90,28 @@ export class JellyinPlaystateService {
|
||||
playbackProgressInfo: {
|
||||
IsPaused: paused,
|
||||
ItemId: track.id,
|
||||
PositionTicks: track.playbackProgress * 10000,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@Interval(1000)
|
||||
private async onPlaybackProgress() {
|
||||
const track = this.playbackService.getPlaylistOrDefault().getActiveTrack();
|
||||
|
||||
if (!track) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.playstateApi.reportPlaybackProgress({
|
||||
playbackProgressInfo: {
|
||||
ItemId: track.id,
|
||||
PositionTicks: track.playbackProgress * 10000,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.verbose(
|
||||
`Reported playback progress ${track.playbackProgress} to Jellyfin for item ${track.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -70,7 +70,8 @@ export class PlayItemCommand {
|
||||
embeds: [
|
||||
this.discordMessageService.buildMessage({
|
||||
title: 'No results found',
|
||||
description: "- Check for any misspellings\n- Grant me access to your desired libraries\n- Avoid special characters",
|
||||
description:
|
||||
'- Check for any misspellings\n- Grant me access to your desired libraries\n- Avoid special characters',
|
||||
}),
|
||||
],
|
||||
ephemeral: true,
|
||||
@ -136,15 +137,8 @@ export class PlayItemCommand {
|
||||
|
||||
const focusedAutoCompleteAction = interaction.options.getFocused(true);
|
||||
const typeIndex = interaction.options.getInteger('type');
|
||||
|
||||
if (typeIndex === null) {
|
||||
this.logger.error(
|
||||
`Failed to get type integer from play command interaction autocomplete`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = Object.values(SearchType)[typeIndex] as SearchType;
|
||||
const type =
|
||||
typeIndex !== null ? Object.values(SearchType)[typeIndex] : undefined;
|
||||
const searchQuery = focusedAutoCompleteAction.value;
|
||||
|
||||
if (!searchQuery || searchQuery.length < 1) {
|
||||
|
@ -22,16 +22,19 @@ import {
|
||||
InteractionUpdateOptions,
|
||||
} from 'discord.js';
|
||||
|
||||
import { PlaybackService } from '../../playback/playback.service';
|
||||
import { chunkArray } from '../../utils/arrayUtils';
|
||||
import { Constants } from '../../utils/constants';
|
||||
import { formatMillisecondsAsHumanReadable } from '../../utils/timeUtils';
|
||||
import { DiscordMessageService } from '../../clients/discord/discord.message.service';
|
||||
import { Track } from '../../models/shared/Track';
|
||||
import { trimStringToFixedLength } from '../../utils/stringUtils/stringUtils';
|
||||
import { PlaybackService } from '../../playback/playback.service';
|
||||
import { chunkArray } from '../../utils/arrayUtils';
|
||||
import { trimStringToFixedLength, zeroPad } from '../../utils/stringUtils/stringUtils';
|
||||
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { lightFormat } from 'date-fns';
|
||||
import { PlaylistInteractionCollector } from './playlist.interaction-collector';
|
||||
import { PlaylistCommandParams } from './playlist.params';
|
||||
import { PlaylistTempCommandData } from './playlist.types';
|
||||
import { tr } from 'date-fns/locale';
|
||||
import { takeCoverage } from 'v8';
|
||||
|
||||
@Injectable()
|
||||
@Command({
|
||||
@ -41,7 +44,7 @@ import { PlaylistCommandParams } from './playlist.params';
|
||||
@UseInterceptors(CollectorInterceptor)
|
||||
@UseCollectors(PlaylistInteractionCollector)
|
||||
export class PlaylistCommand {
|
||||
public pageData: Map<string, number> = new Map();
|
||||
public pageData: Map<string, PlaylistTempCommandData> = new Map();
|
||||
private readonly logger = new Logger(PlaylistCommand.name);
|
||||
|
||||
constructor(
|
||||
@ -61,7 +64,10 @@ export class PlaylistCommand {
|
||||
this.getReplyForPage(page) as InteractionReplyOptions,
|
||||
);
|
||||
|
||||
this.pageData.set(interaction.id, page);
|
||||
this.pageData.set(interaction.id, {
|
||||
page,
|
||||
interaction,
|
||||
});
|
||||
this.logger.debug(
|
||||
`Added '${interaction.id}' as a message id for page storage`,
|
||||
);
|
||||
@ -82,6 +88,36 @@ export class PlaylistCommand {
|
||||
return chunkArray(playlist.tracks, 10);
|
||||
}
|
||||
|
||||
private createInterval(interaction: CommandInteraction) {
|
||||
return setInterval(async () => {
|
||||
const tempData = this.pageData.get(interaction.id);
|
||||
|
||||
if (!tempData) {
|
||||
this.logger.warn(
|
||||
`Failed to update from interval, because temp data was not found`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.editReply(this.getReplyForPage(tempData.page));
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
@Interval(2 * 1000)
|
||||
private async updatePlaylists() {
|
||||
if (this.pageData.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.verbose(
|
||||
`Updating playlist for ${this.pageData.size} playlist datas`,
|
||||
);
|
||||
|
||||
this.pageData.forEach(async (value) => {
|
||||
await value.interaction.editReply(this.getReplyForPage(value.page));
|
||||
});
|
||||
}
|
||||
|
||||
public getReplyForPage(
|
||||
page: number,
|
||||
): InteractionReplyOptions | InteractionUpdateOptions {
|
||||
@ -176,26 +212,34 @@ export class PlaylistCommand {
|
||||
);
|
||||
}
|
||||
|
||||
const paddingNumber = playlist.getLength() >= 100 ? 3 : 2;
|
||||
|
||||
const content = chunk
|
||||
.map((track, index) => {
|
||||
const isCurrent = track === playlist.getActiveTrack();
|
||||
|
||||
// use the offset for the page, add the current index and offset by one because the array index is used
|
||||
let point = `${offset + index + 1}. `;
|
||||
point += `**${trimStringToFixedLength(track.name, 30)}**`;
|
||||
|
||||
let line = `\`\`${zeroPad(offset + index + 1, paddingNumber)}.\`\` `;
|
||||
line += this.getTrackName(track, isCurrent) + ' • ';
|
||||
if (isCurrent) {
|
||||
point += ' :loud_sound:';
|
||||
line += lightFormat(track.getPlaybackProgress(), 'mm:ss') + ' / ';
|
||||
}
|
||||
|
||||
point += '\n';
|
||||
point += Constants.Design.InvisibleSpace.repeat(2);
|
||||
point += formatMillisecondsAsHumanReadable(track.getDuration());
|
||||
|
||||
return point;
|
||||
line += lightFormat(track.getDuration(), 'mm:ss');
|
||||
if (isCurrent) {
|
||||
line += ' • (:play_pause:)';
|
||||
}
|
||||
return line;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return new EmbedBuilder().setTitle('Your playlist').setDescription(content);
|
||||
}
|
||||
|
||||
private getTrackName(track: Track, active: boolean) {
|
||||
const trimmedTitle = trimStringToFixedLength(track.name, 30);
|
||||
if (active) {
|
||||
return `**${trimmedTitle}**`;
|
||||
}
|
||||
|
||||
return trimmedTitle;
|
||||
}
|
||||
}
|
||||
|
@ -15,6 +15,7 @@ import {
|
||||
} from 'discord.js';
|
||||
|
||||
import { PlaylistCommand } from './playlist.command';
|
||||
import { PlaylistTempCommandData } from './playlist.types';
|
||||
|
||||
@Injectable({ scope: Scope.REQUEST })
|
||||
@InteractionEventCollector({ time: 60 * 1000 })
|
||||
@ -40,7 +41,7 @@ export class PlaylistInteractionCollector {
|
||||
async onCollect(interaction: ButtonInteraction): Promise<void> {
|
||||
const targetPage = this.getInteraction(interaction);
|
||||
this.logger.verbose(
|
||||
`Extracted the target page ${targetPage} from the button interaction`,
|
||||
`Extracted the target page '${targetPage?.page}' from the button interaction`,
|
||||
);
|
||||
|
||||
if (targetPage === undefined) {
|
||||
@ -51,14 +52,16 @@ export class PlaylistInteractionCollector {
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Updating current page for interaction ${this.causeInteraction.id} to ${targetPage}`,
|
||||
`Updating current page for interaction ${this.causeInteraction.id} to ${targetPage.page}`,
|
||||
);
|
||||
this.playlistCommand.pageData.set(this.causeInteraction.id, targetPage);
|
||||
const reply = this.playlistCommand.getReplyForPage(targetPage);
|
||||
const reply = this.playlistCommand.getReplyForPage(targetPage.page);
|
||||
await interaction.update(reply as InteractionUpdateOptions);
|
||||
}
|
||||
|
||||
private getInteraction(interaction: ButtonInteraction): number | undefined {
|
||||
private getInteraction(
|
||||
interaction: ButtonInteraction,
|
||||
): PlaylistTempCommandData | undefined {
|
||||
const current = this.playlistCommand.pageData.get(this.causeInteraction.id);
|
||||
|
||||
if (current === undefined) {
|
||||
@ -78,12 +81,18 @@ export class PlaylistInteractionCollector {
|
||||
|
||||
switch (interaction.customId) {
|
||||
case 'playlist-controls-next':
|
||||
return current + 1;
|
||||
return {
|
||||
...current,
|
||||
page: current.page + 1,
|
||||
};
|
||||
case 'playlist-controls-previous':
|
||||
return current - 1;
|
||||
return {
|
||||
...current,
|
||||
page: current.page - 1,
|
||||
};
|
||||
default:
|
||||
this.logger.error(
|
||||
"Unable to map button interaction from collector to target page",
|
||||
'Unable to map button interaction from collector to target page',
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
6
src/commands/playlist/playlist.types.ts
Normal file
6
src/commands/playlist/playlist.types.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { CommandInteraction } from 'discord.js';
|
||||
|
||||
export type PlaylistTempCommandData = {
|
||||
page: number;
|
||||
interaction: CommandInteraction;
|
||||
};
|
@ -2,6 +2,7 @@ import {
|
||||
BaseItemDto,
|
||||
SearchHint as JellyfinSearchHint,
|
||||
} from '@jellyfin/sdk/lib/generated-client/models';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service';
|
||||
import { Track } from '../shared/Track';
|
||||
@ -26,12 +27,27 @@ export class SearchHint {
|
||||
}
|
||||
|
||||
static constructFromHint(hint: JellyfinSearchHint) {
|
||||
if (hint.Id === undefined || !hint.Name || !hint.RunTimeTicks) {
|
||||
const schema = z.object({
|
||||
Id: z.string(),
|
||||
Name: z.string(),
|
||||
RunTimeTicks: z.number(),
|
||||
});
|
||||
|
||||
const result = schema.safeParse(hint);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
'Unable to construct search hint, required properties were undefined',
|
||||
`Unable to construct search hint, required properties were undefined: ${JSON.stringify(
|
||||
hint,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
return new SearchHint(hint.Id, hint.Name, hint.RunTimeTicks / 10000);
|
||||
|
||||
return new SearchHint(
|
||||
result.data.Id,
|
||||
result.data.Name,
|
||||
result.data.RunTimeTicks / 10000,
|
||||
);
|
||||
}
|
||||
|
||||
static constructFromBaseItem(baseItem: BaseItemDto) {
|
||||
|
@ -29,6 +29,8 @@ export class Track {
|
||||
|
||||
playing: boolean;
|
||||
|
||||
playbackProgress: number;
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
name: string,
|
||||
@ -40,6 +42,7 @@ export class Track {
|
||||
this.duration = duration;
|
||||
this.remoteImages = remoteImages;
|
||||
this.playing = false;
|
||||
this.playbackProgress = 0;
|
||||
}
|
||||
|
||||
getDuration() {
|
||||
@ -53,4 +56,12 @@ export class Track {
|
||||
getRemoteImages(): RemoteImageInfo[] {
|
||||
return this.remoteImages?.Images ?? [];
|
||||
}
|
||||
|
||||
getPlaybackProgress() {
|
||||
return this.playbackProgress;
|
||||
}
|
||||
|
||||
updatePlaybackProgress(progress: number) {
|
||||
this.playbackProgress = progress;
|
||||
}
|
||||
}
|
||||
|
@ -11,3 +11,6 @@ export const trimStringToFixedLength = (value: string, maxLength: number) => {
|
||||
|
||||
return value.substring(0, upperBound) + '...';
|
||||
};
|
||||
|
||||
export const zeroPad = (num: number, places: number) =>
|
||||
String(num).padStart(places, '0');
|
||||
|
@ -5403,3 +5403,8 @@ yocto-queue@^0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
||||
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
|
||||
|
||||
zod@^3.21.4:
|
||||
version "3.21.4"
|
||||
resolved "https://registry.yarnpkg.com/zod/-/zod-3.21.4.tgz#10882231d992519f0a10b5dd58a38c9dabbb64db"
|
||||
integrity sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==
|
||||
|
Loading…
Reference in New Issue
Block a user