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", "rimraf": "^4.4.1",
"rxjs": "^7.2.0", "rxjs": "^7.2.0",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"ws": "^8.13.0" "ws": "^8.13.0",
"zod": "^3.21.4"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^9.3.0", "@nestjs/cli": "^9.3.0",

View File

@ -14,6 +14,7 @@ import {
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Logger } from '@nestjs/common/services'; import { Logger } from '@nestjs/common/services';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import { Interval } from '@nestjs/schedule';
import { GuildMember } from 'discord.js'; import { GuildMember } from 'discord.js';
@ -224,7 +225,7 @@ export class DiscordVoiceService {
if (this.audioPlayer === undefined) { if (this.audioPlayer === undefined) {
this.logger.debug( this.logger.debug(
"Initialized new instance of AudioPlayer because it has not been defined yet", 'Initialized new instance of AudioPlayer because it has not been defined yet',
); );
this.audioPlayer = createAudioPlayer({ this.audioPlayer = createAudioPlayer({
debug: process.env.DEBUG?.toLowerCase() === 'true', debug: process.env.DEBUG?.toLowerCase() === 'true',
@ -291,7 +292,7 @@ export class DiscordVoiceService {
return; 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 playlist = this.playbackService.getPlaylistOrDefault();
const finishedTrack = playlist.getActiveTrack(); const finishedTrack = playlist.getActiveTrack();
@ -308,11 +309,43 @@ export class DiscordVoiceService {
); );
if (!hasNextTrack) { if (!hasNextTrack) {
this.logger.debug("Reached the end of the playlist"); this.logger.debug('Reached the end of the playlist');
return; return;
} }
this.playbackService.getPlaylistOrDefault().setNextTrackAsActiveTrack(); this.playbackService.getPlaylistOrDefault().setNextTrackAsActiveTrack();
}); });
} }
@Interval(500)
private checkAudioResourcePlayback() {
if (!this.audioResource) {
return;
}
const progress = this.audioResource.playbackDuration;
const playlist = this.playbackService.getPlaylistOrDefault();
if (!playlist) {
this.logger.error(
`Failed to update ellapsed audio time because playlist was unexpectitly undefined`,
);
return;
}
const activeTrack = playlist.getActiveTrack();
if (!activeTrack) {
this.logger.error(
`Failed to update ellapsed audio time because active track was unexpectitly undefined`,
);
return;
}
activeTrack.updatePlaybackProgress(progress);
this.logger.verbose(
`Reporting progress: ${progress} on track ${activeTrack.id}`,
);
}
} }

View File

@ -10,6 +10,7 @@ import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import { Interval } from '@nestjs/schedule';
import { Track } from '../../models/shared/Track'; import { Track } from '../../models/shared/Track';
import { PlaybackService } from '../../playback/playback.service'; import { PlaybackService } from '../../playback/playback.service';
@ -52,6 +53,7 @@ export class JellyinPlaystateService {
await this.playstateApi.reportPlaybackStart({ await this.playstateApi.reportPlaybackStart({
playbackStartInfo: { playbackStartInfo: {
ItemId: track.id, ItemId: track.id,
PositionTicks: 0,
}, },
}); });
} }
@ -60,7 +62,7 @@ export class JellyinPlaystateService {
private async onPlaybackFinished(track: Track) { private async onPlaybackFinished(track: Track) {
if (!track) { if (!track) {
this.logger.error( this.logger.error(
"Unable to report playback because finished track was undefined", 'Unable to report playback because finished track was undefined',
); );
return; return;
} }
@ -68,6 +70,7 @@ export class JellyinPlaystateService {
await this.playstateApi.reportPlaybackStopped({ await this.playstateApi.reportPlaybackStopped({
playbackStopInfo: { playbackStopInfo: {
ItemId: track.id, ItemId: track.id,
PositionTicks: track.playbackProgress * 10000,
}, },
}); });
} }
@ -78,7 +81,7 @@ export class JellyinPlaystateService {
if (!track) { if (!track) {
this.logger.error( 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; return;
} }
@ -87,7 +90,28 @@ export class JellyinPlaystateService {
playbackProgressInfo: { playbackProgressInfo: {
IsPaused: paused, IsPaused: paused,
ItemId: track.id, 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: [ embeds: [
this.discordMessageService.buildMessage({ this.discordMessageService.buildMessage({
title: 'No results found', title: 'No results found',
description: "- Check for any misspellings\n- Grant me access to your desired libraries\n- Avoid special characters", description:
'- Check for any misspellings\n- Grant me access to your desired libraries\n- Avoid special characters',
}), }),
], ],
ephemeral: true, ephemeral: true,
@ -136,15 +137,8 @@ export class PlayItemCommand {
const focusedAutoCompleteAction = interaction.options.getFocused(true); const focusedAutoCompleteAction = interaction.options.getFocused(true);
const typeIndex = interaction.options.getInteger('type'); const typeIndex = interaction.options.getInteger('type');
const type =
if (typeIndex === null) { typeIndex !== null ? Object.values(SearchType)[typeIndex] : undefined;
this.logger.error(
`Failed to get type integer from play command interaction autocomplete`,
);
return;
}
const type = Object.values(SearchType)[typeIndex] as SearchType;
const searchQuery = focusedAutoCompleteAction.value; const searchQuery = focusedAutoCompleteAction.value;
if (!searchQuery || searchQuery.length < 1) { if (!searchQuery || searchQuery.length < 1) {

View File

@ -22,16 +22,19 @@ import {
InteractionUpdateOptions, InteractionUpdateOptions,
} from 'discord.js'; } from 'discord.js';
import { PlaybackService } from '../../playback/playback.service';
import { chunkArray } from '../../utils/arrayUtils';
import { Constants } from '../../utils/constants';
import { formatMillisecondsAsHumanReadable } from '../../utils/timeUtils';
import { DiscordMessageService } from '../../clients/discord/discord.message.service'; import { DiscordMessageService } from '../../clients/discord/discord.message.service';
import { Track } from '../../models/shared/Track'; import { Track } from '../../models/shared/Track';
import { trimStringToFixedLength } from '../../utils/stringUtils/stringUtils'; import { PlaybackService } from '../../playback/playback.service';
import { chunkArray } from '../../utils/arrayUtils';
import { trimStringToFixedLength, zeroPad } from '../../utils/stringUtils/stringUtils';
import { Interval } from '@nestjs/schedule';
import { lightFormat } from 'date-fns';
import { PlaylistInteractionCollector } from './playlist.interaction-collector'; import { PlaylistInteractionCollector } from './playlist.interaction-collector';
import { PlaylistCommandParams } from './playlist.params'; import { PlaylistCommandParams } from './playlist.params';
import { PlaylistTempCommandData } from './playlist.types';
import { tr } from 'date-fns/locale';
import { takeCoverage } from 'v8';
@Injectable() @Injectable()
@Command({ @Command({
@ -41,7 +44,7 @@ import { PlaylistCommandParams } from './playlist.params';
@UseInterceptors(CollectorInterceptor) @UseInterceptors(CollectorInterceptor)
@UseCollectors(PlaylistInteractionCollector) @UseCollectors(PlaylistInteractionCollector)
export class PlaylistCommand { export class PlaylistCommand {
public pageData: Map<string, number> = new Map(); public pageData: Map<string, PlaylistTempCommandData> = new Map();
private readonly logger = new Logger(PlaylistCommand.name); private readonly logger = new Logger(PlaylistCommand.name);
constructor( constructor(
@ -61,7 +64,10 @@ export class PlaylistCommand {
this.getReplyForPage(page) as InteractionReplyOptions, this.getReplyForPage(page) as InteractionReplyOptions,
); );
this.pageData.set(interaction.id, page); this.pageData.set(interaction.id, {
page,
interaction,
});
this.logger.debug( this.logger.debug(
`Added '${interaction.id}' as a message id for page storage`, `Added '${interaction.id}' as a message id for page storage`,
); );
@ -82,6 +88,36 @@ export class PlaylistCommand {
return chunkArray(playlist.tracks, 10); return chunkArray(playlist.tracks, 10);
} }
private createInterval(interaction: CommandInteraction) {
return setInterval(async () => {
const tempData = this.pageData.get(interaction.id);
if (!tempData) {
this.logger.warn(
`Failed to update from interval, because temp data was not found`,
);
return;
}
await interaction.editReply(this.getReplyForPage(tempData.page));
}, 2000);
}
@Interval(2 * 1000)
private async updatePlaylists() {
if (this.pageData.size === 0) {
return;
}
this.logger.verbose(
`Updating playlist for ${this.pageData.size} playlist datas`,
);
this.pageData.forEach(async (value) => {
await value.interaction.editReply(this.getReplyForPage(value.page));
});
}
public getReplyForPage( public getReplyForPage(
page: number, page: number,
): InteractionReplyOptions | InteractionUpdateOptions { ): InteractionReplyOptions | InteractionUpdateOptions {
@ -176,26 +212,34 @@ export class PlaylistCommand {
); );
} }
const paddingNumber = playlist.getLength() >= 100 ? 3 : 2;
const content = chunk const content = chunk
.map((track, index) => { .map((track, index) => {
const isCurrent = track === playlist.getActiveTrack(); const isCurrent = track === playlist.getActiveTrack();
// use the offset for the page, add the current index and offset by one because the array index is used let line = `\`\`${zeroPad(offset + index + 1, paddingNumber)}.\`\` `;
let point = `${offset + index + 1}. `; line += this.getTrackName(track, isCurrent) + ' • ';
point += `**${trimStringToFixedLength(track.name, 30)}**`;
if (isCurrent) { if (isCurrent) {
point += ' :loud_sound:'; line += lightFormat(track.getPlaybackProgress(), 'mm:ss') + ' / ';
} }
line += lightFormat(track.getDuration(), 'mm:ss');
point += '\n'; if (isCurrent) {
point += Constants.Design.InvisibleSpace.repeat(2); line += ' • (:play_pause:)';
point += formatMillisecondsAsHumanReadable(track.getDuration()); }
return line;
return point;
}) })
.join('\n'); .join('\n');
return new EmbedBuilder().setTitle('Your playlist').setDescription(content); return new EmbedBuilder().setTitle('Your playlist').setDescription(content);
} }
private getTrackName(track: Track, active: boolean) {
const trimmedTitle = trimStringToFixedLength(track.name, 30);
if (active) {
return `**${trimmedTitle}**`;
}
return trimmedTitle;
}
} }

View File

@ -15,6 +15,7 @@ import {
} from 'discord.js'; } from 'discord.js';
import { PlaylistCommand } from './playlist.command'; import { PlaylistCommand } from './playlist.command';
import { PlaylistTempCommandData } from './playlist.types';
@Injectable({ scope: Scope.REQUEST }) @Injectable({ scope: Scope.REQUEST })
@InteractionEventCollector({ time: 60 * 1000 }) @InteractionEventCollector({ time: 60 * 1000 })
@ -40,7 +41,7 @@ export class PlaylistInteractionCollector {
async onCollect(interaction: ButtonInteraction): Promise<void> { async onCollect(interaction: ButtonInteraction): Promise<void> {
const targetPage = this.getInteraction(interaction); const targetPage = this.getInteraction(interaction);
this.logger.verbose( this.logger.verbose(
`Extracted the target page ${targetPage} from the button interaction`, `Extracted the target page '${targetPage?.page}' from the button interaction`,
); );
if (targetPage === undefined) { if (targetPage === undefined) {
@ -51,14 +52,16 @@ export class PlaylistInteractionCollector {
} }
this.logger.debug( this.logger.debug(
`Updating current page for interaction ${this.causeInteraction.id} to ${targetPage}`, `Updating current page for interaction ${this.causeInteraction.id} to ${targetPage.page}`,
); );
this.playlistCommand.pageData.set(this.causeInteraction.id, targetPage); this.playlistCommand.pageData.set(this.causeInteraction.id, targetPage);
const reply = this.playlistCommand.getReplyForPage(targetPage); const reply = this.playlistCommand.getReplyForPage(targetPage.page);
await interaction.update(reply as InteractionUpdateOptions); await interaction.update(reply as InteractionUpdateOptions);
} }
private getInteraction(interaction: ButtonInteraction): number | undefined { private getInteraction(
interaction: ButtonInteraction,
): PlaylistTempCommandData | undefined {
const current = this.playlistCommand.pageData.get(this.causeInteraction.id); const current = this.playlistCommand.pageData.get(this.causeInteraction.id);
if (current === undefined) { if (current === undefined) {
@ -78,12 +81,18 @@ export class PlaylistInteractionCollector {
switch (interaction.customId) { switch (interaction.customId) {
case 'playlist-controls-next': case 'playlist-controls-next':
return current + 1; return {
...current,
page: current.page + 1,
};
case 'playlist-controls-previous': case 'playlist-controls-previous':
return current - 1; return {
...current,
page: current.page - 1,
};
default: default:
this.logger.error( this.logger.error(
"Unable to map button interaction from collector to target page", 'Unable to map button interaction from collector to target page',
); );
return undefined; return undefined;
} }

View File

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

View File

@ -2,6 +2,7 @@ import {
BaseItemDto, BaseItemDto,
SearchHint as JellyfinSearchHint, SearchHint as JellyfinSearchHint,
} from '@jellyfin/sdk/lib/generated-client/models'; } from '@jellyfin/sdk/lib/generated-client/models';
import { z } from 'zod';
import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service'; import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service';
import { Track } from '../shared/Track'; import { Track } from '../shared/Track';
@ -26,12 +27,27 @@ export class SearchHint {
} }
static constructFromHint(hint: JellyfinSearchHint) { 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( 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) { static constructFromBaseItem(baseItem: BaseItemDto) {

View File

@ -29,6 +29,8 @@ export class Track {
playing: boolean; playing: boolean;
playbackProgress: number;
constructor( constructor(
id: string, id: string,
name: string, name: string,
@ -40,6 +42,7 @@ export class Track {
this.duration = duration; this.duration = duration;
this.remoteImages = remoteImages; this.remoteImages = remoteImages;
this.playing = false; this.playing = false;
this.playbackProgress = 0;
} }
getDuration() { getDuration() {
@ -53,4 +56,12 @@ export class Track {
getRemoteImages(): RemoteImageInfo[] { getRemoteImages(): RemoteImageInfo[] {
return this.remoteImages?.Images ?? []; return this.remoteImages?.Images ?? [];
} }
getPlaybackProgress() {
return this.playbackProgress;
}
updatePlaybackProgress(progress: number) {
this.playbackProgress = progress;
}
} }

View File

@ -11,3 +11,6 @@ export const trimStringToFixedLength = (value: string, maxLength: number) => {
return value.substring(0, upperBound) + '...'; return value.substring(0, upperBound) + '...';
}; };
export const zeroPad = (num: number, places: number) =>
String(num).padStart(places, '0');

View File

@ -5403,3 +5403,8 @@ yocto-queue@^0.1.0:
version "0.1.0" version "0.1.0"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== 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==