Add time tracking

This commit is contained in:
Manuel 2023-04-02 19:31:01 +02:00 committed by Manuel
parent 831e03a77f
commit d1fc61c6fe
11 changed files with 190 additions and 44 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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