Bunch of updates and fixes

rebuild option in rescan
request command to request songs
music downloader classes
misc fixes
This commit is contained in:
Erik 2024-03-27 18:14:37 +02:00
parent 671623dca7
commit 822bf608f3
20 changed files with 529 additions and 72 deletions

View File

@ -29,7 +29,8 @@
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module"
"sourceType": "module",
"project": "./tsconfig.json"
},
"rules": {
"@typescript-eslint/no-unused-vars": "off",

7
@types/Downloader.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
export type DownloaderResult = {
filePath: string,
artist: string,
song: string,
album: string,
ext: string
};

View File

@ -10,10 +10,12 @@ export type MusicPlayerOptions = {
}
export type MusicIndexEntry = {
arist: string,
id: number,
artist: string,
title: string,
album?: string,
year?: number,
genre: string[]
file: string,
stats: {
plays: number,
@ -24,10 +26,12 @@ export type MusicIndexEntry = {
export type MusicQuery = {
title?: string
artist?: string,
keyword?: string
keyword?: string,
id?: number
}
export type QueueOrder = {
artist?: string,
title: string
title?: string,
id?: number
}

View File

@ -10,6 +10,9 @@ RUN yarn install
FROM node:lts-alpine
RUN apk update && apk add ffmpeg
# RUN python3 -m ensurepip
# RUN python3 -m pip install --user pipx
# RUN pipx install https://get.zotify.xyz
WORKDIR /musicbot
COPY build build
COPY --from=builder /musicbot/node_modules ./node_modules

View File

@ -1,3 +1,3 @@
# music-bot
A bot that plays music on Discord
A bot that plays music on Discord using your local library

View File

@ -14,15 +14,16 @@
"debug": "node --trace-warnings --inspect index.js",
"lint": "eslint --fix src/",
"build": "tsc --build",
"docker:pubublish": "docker push git.corgi.wtf/navy.gif/music-bot:latest",
"docker:publish": "docker push git.corgi.wtf/navy.gif/music-bot:latest",
"docker:build": "yarn build && docker build . --tag git.corgi.wtf/navy.gif/music-bot:latest"
},
"dependencies": {
"@discordjs/opus": "^0.9.0",
"@discordjs/voice": "^0.16.1",
"@navy.gif/commandparser": "^1.6.5",
"@navy.gif/commandparser": "^1.6.6",
"@navy.gif/logger": "^2.5.4",
"@navy.gif/timestring": "^6.0.6",
"@types/node": "^20.11.30",
"bufferutil": "^4.0.8",
"common-tags": "^1.8.2",
"discord.js": "^14.14.1",

View File

@ -12,6 +12,7 @@ import Command from '../interfaces/Command.js';
import Resolver from './components/Resolver.js';
import Inhibitor from '../interfaces/Inhibitor.js';
import EventHooker from './components/EventHooker.js';
import Downloader from '../interfaces/Downloader.js';
class DiscordClient extends Client
{
@ -68,6 +69,7 @@ class DiscordClient extends Client
// process.on('message', this.#handleMessage.bind(this));
process.on('SIGINT', () => this.shutdown());
process.on('SIGTERM', () => this.shutdown());
this.#built = false;
}
@ -81,6 +83,7 @@ class DiscordClient extends Client
await this.#registry.loadComponents('components/commands', Command);
await this.#registry.loadComponents('components/inhibitors', Inhibitor);
await this.#registry.loadComponents('components/observers', Observer);
await this.#registry.loadComponents('components/downloaders', Downloader);
this.#logger.info('Initialising events');
await this.#eventHooker.init();

View File

@ -0,0 +1,80 @@
import fs from 'node:fs';
import { Collection } from 'discord.js';
import { LoggerClient } from '@navy.gif/logger';
import Initialisable from '../../interfaces/Initialisable.js';
import Downloader from '../../interfaces/Downloader.js';
import DiscordClient from '../DiscordClient.js';
class MusicDownloader implements Initialisable
{
#ready: boolean;
#availableDownloaders: Collection<string, Downloader>;
#logger: LoggerClient;
#client: DiscordClient;
#downloadDir: string;
constructor (client: DiscordClient)
{
this.#ready = false;
this.#logger = client.createLogger(this);
this.#client = client;
this.#availableDownloaders = new Collection();
this.#downloadDir = 'Z:\\media\\downloads';
}
async initialise (): Promise<void>
{
if (this.#ready)
return;
this.#logger.info('Initialising music downloader');
this.#ready = true;
const { downloaders } = this.#client.registry;
if (!fs.existsSync(this.#downloadDir))
fs.mkdirSync(this.#downloadDir);
for (const downloader of downloaders.values())
{
this.#availableDownloaders.set(downloader.name, downloader);
downloader.downloadDir = this.#downloadDir;
}
await this.#testDownloaders();
}
stop (): void | Promise<void>
{
throw new Error('Method not implemented.');
}
async download (name: string, url: string)
{
const downloader = this.#availableDownloaders.get(name);
if (!downloader)
throw new Error('No such downloader');
if (!downloader.available)
throw new Error('Downloader not available');
return downloader.download(url);
}
async #testDownloaders ()
{
for (const downloader of this.#availableDownloaders.values())
{
try
{
await downloader.test();
}
catch (err)
{
this.#logger.info(`Downloader ${downloader.name} is unavailable`);
}
}
}
get ready ()
{
return this.#ready;
}
}
export default MusicDownloader;

View File

@ -9,19 +9,26 @@ import DiscordClient from '../DiscordClient.js';
import { Collection } from 'discord.js';
import { MusicIndexEntry, MusicQuery } from '../../../@types/MusicPlayer.js';
import similarity from 'similarity';
import MusicDownloader from './MusicDownloader.js';
import MusicPlayerError from '../../errors/MusicPlayerError.js';
const linkReg = /(https?:\/\/(www\.)?)?(?<domain>([a-z0-9-]{1,63}\.)?([a-z0-9-]{1,63})(\.[a-z0-9-]{2,63})(\.[a-z0-9-]{2,63})?)(\/[^()\s]*)?/iu;
class MusicLibrary implements Initialisable
{
#path: string;
#ready: boolean;
#index: Collection<string, MusicIndexEntry>;
#logger: LoggerClient;
#currentId: number;
#downloader: MusicDownloader;
constructor (client: DiscordClient, libraryPath: string)
{
this.#path = libraryPath;
this.#ready = false;
this.#index = new Collection();
this.#downloader = new MusicDownloader(client);
this.#logger = client.createLogger(this);
this.#currentId = 0;
}
get ready ()
@ -40,6 +47,7 @@ class MusicLibrary implements Initialisable
return;
this.#logger.info('Initialising music library');
this.loadIndex();
await this.#downloader.initialise();
await this.scanLibrary();
this.#ready = true;
this.#logger.info(`Music library initialised with ${this.#index.size} entries`);
@ -50,6 +58,35 @@ class MusicLibrary implements Initialisable
this.saveIndex();
}
async download (keyword: string)
{
if (!linkReg.test(keyword))
throw new MusicPlayerError('Invalid link');
const match = keyword.match(linkReg);
if (!match || !match.groups)
throw new Error('Missing match??');
const { domain } = match.groups;
if (!domain)
throw new MusicPlayerError('Invalid link');
let result = null;
if (domain.includes('spotify.com'))
result = await this.#downloader.download('spotify', keyword);
else
throw new MusicPlayerError('Unsupported domain');
if (!result)
return null;
const targetDir = path.join(this.#path, result.artist);
if (!fs.existsSync(targetDir))
fs.mkdirSync(targetDir);
const newFile = path.join(targetDir, `${result.song} - ${result.album}.${result.ext}`);
fs.renameSync(result.filePath, newFile);
const entry = await this.#indexSong(newFile, false);
return entry;
}
search (query: MusicQuery)
{
if (!Object.keys(query).length)
@ -57,13 +94,15 @@ class MusicLibrary implements Initialisable
const results: MusicIndexEntry[] = [];
for (const entry of this.#index.values())
{
if (query.artist && !entry.arist.toLowerCase().includes(query.artist.toLowerCase()))
if (query.id && entry.id === query.id)
return [ entry ];
if (query.artist && !entry.artist.toLowerCase().includes(query.artist.toLowerCase()))
continue;
if (query.title && !entry.title.toLowerCase().includes(query.title.toLowerCase()))
continue;
if (query.keyword
&& !entry.title.toLowerCase().includes(query.keyword?.toLowerCase())
&& !entry.arist.toLowerCase().includes(query.keyword.toLowerCase()))
&& !entry.artist.toLowerCase().includes(query.keyword.toLowerCase()))
continue;
results.push(entry);
}
@ -71,7 +110,7 @@ class MusicLibrary implements Initialisable
{
if (query.artist)
{
if (similarity(a.arist, query.artist) > similarity(b.arist, query.artist))
if (similarity(a.artist, query.artist) > similarity(b.artist, query.artist))
return 1;
return -1;
}
@ -94,7 +133,10 @@ class MusicLibrary implements Initialisable
getShufflePlaylist ()
{
const entries = [ ...this.#index.values() ];
return Util.shuffle(entries);
return Util.shuffle(entries).sort((a, b) =>
{
return a.stats.plays - b.stats.plays;
});
}
countPlay (fp: string)
@ -124,32 +166,9 @@ class MusicLibrary implements Initialisable
// Progress reporting
idx++;
if (idx % 50 === 0 || idx === filePaths.length)
this.#logger.info(`Scan progress: ${(idx / filePaths.length * 100).toFixed(2)}%`);
this.#logger.status(`Scan progress: ${(idx / filePaths.length * 100).toFixed(2)}%`);
// Skip already scanned files
if (this.#index.has(fp) && !full)
continue;
// Expensive call
const metadata = await parseFile(fp, { skipCovers: true });
const { common } = metadata;
// Fall back to file and folder name if artist or title not available
const segmets = fp.replace(this.#path, '').split(path.sep);
const artist = segmets[segmets.length - 2];
const title = segmets[segmets.length - 1].replace((/\..+$/u), '');
const entry = {
arist: common.artist ?? artist,
title: common.title ?? title,
album: common.album,
year: common.year,
file: fp,
stats: {
plays: 0,
skips: 0
}
};
this.#index.set(fp, entry);
await this.#indexSong(fp, full);
}
const end = Date.now();
const newFiles = this.#index.size - initialSize;
@ -158,6 +177,37 @@ class MusicLibrary implements Initialisable
return newFiles;
}
async #indexSong (fp: string, force: boolean)
{
// Skip already scanned files
if (this.#index.has(fp) && !force)
return;
// Expensive call
const metadata = await parseFile(fp, { skipCovers: true });
const { common } = metadata;
// Fall back to file and folder name if artist or title not available
const segmets = fp.replace(this.#path, '').split(path.sep);
const artist = segmets[segmets.length - 2];
const title = segmets[segmets.length - 1].replace((/\..+$/u), '');
const entry = {
id: this.#currentId++,
artist: common.artist ?? artist,
title: common.title ?? title,
album: common.album,
year: common.year,
genre: common.genre ?? [],
file: fp,
stats: {
plays: 0,
skips: 0
}
};
this.#index.set(fp, entry);
return entry;
}
loadIndex ()
{
this.#logger.info('Loading index');
@ -168,8 +218,8 @@ class MusicLibrary implements Initialisable
const parsed = JSON.parse(raw) as MusicIndexEntry[];
for (const entry of parsed)
{
if (typeof entry.stats === 'undefined')
entry.stats = { plays: 0, skips: 0 };
if (entry.id >= this.#currentId)
this.#currentId = entry.id + 1;
this.#index.set(entry.file, entry);
}
this.#logger.info(`Index loaded with ${this.#index.size} entries`);

View File

@ -1,3 +1,5 @@
import fs from 'node:fs';
import { ActivityType, ChannelType, Collection, Guild, GuildTextBasedChannel, VoiceChannel } from 'discord.js';
import { AudioPlayer, AudioPlayerStatus, AudioResource, PlayerSubscription, VoiceConnection, createAudioPlayer, createAudioResource, joinVoiceChannel } from '@discordjs/voice';
import { LoggerClient } from '@navy.gif/logger';
@ -13,6 +15,7 @@ type ConnectionDetails = {
textOutput: GuildTextBasedChannel | null
}
const cachePath = './cache/player.json';
class MusicPlayer implements Initialisable
{
#client: DiscordClient;
@ -65,8 +68,25 @@ class MusicPlayer implements Initialisable
return this.#library;
}
queue (order: QueueOrder)
get queue ()
{
return Object.freeze(this.#queue);
}
async request (keyword: string)
{
const result = await this.library.download(keyword);
if (!result)
return null;
this.#queue.push(result);
return result;
}
queueSong (order: QueueOrder)
{
if (Object.values(order).every(val => typeof val === 'undefined'))
return null;
const [ result ] = this.library.search(order);
if (!result)
return null;
@ -74,6 +94,12 @@ class MusicPlayer implements Initialisable
return result;
}
reshuffle ()
{
this.#shuffleList = this.#library.getShufflePlaylist();
this.#shuffleIdx = 0;
}
playNext ()
{
if (!this.#ready)
@ -97,43 +123,58 @@ class MusicPlayer implements Initialisable
});
this.#currentResource.volume?.setVolume(this.#volume);
this.#logger.info(`Now playing ${info.arist} - ${info.title}`);
this.#logger.info(`Now playing ${info.artist} - ${info.title}`);
this.#player.play(this.#currentResource);
this.#library.countPlay(info.file);
this.#shuffleIdx++;
if (this.#shuffleIdx === this.#shuffleList.length)
{
this.#shuffleIdx = 0;
this.#shuffleList = this.#library.getShufflePlaylist();
}
this.#client.user?.setPresence({
activities: [{
name: `${info.title} by ${info.arist}`,
name: `${info.title} by ${info.artist}`,
type: ActivityType.Listening
}]
});
const outputChannels = this.#connections.map(obj => obj.textOutput);
outputChannels.forEach(async channel =>
outputChannels.forEach(channel => this.#sendNowPlaying(channel, info));
}
async #sendNowPlaying (channel: GuildTextBasedChannel | null, info: MusicIndexEntry | null)
{
if (!channel || !info)
return;
const payload = {
embeds: [{
title: 'Now playing :notes:',
description: `**${info.title}** by ${info.artist}`,
color: 0xffafff
}]
};
const messages = await channel.messages.fetch({ limit: 100 });
let latest = messages.sort((a, b) => b.createdTimestamp - a.createdTimestamp).first() ?? null;
if (latest?.author.id === this.#client.user!.id && latest.embeds.length)
{
if (!channel)
return;
const messages = await channel.messages.fetch({ limit: 100 });
const filtered = messages.filter(msg => msg.author.id === this.#client.user?.id);
latest.edit(payload);
}
else
{
latest = null;
await channel.send(payload);
}
await channel.send({
embeds: [{
title: 'Now playing :notes:',
description: `**${info!.title}** by ${info!.arist}`,
color: 0xffafff
}]
});
const filtered = messages.filter(msg => msg.author.id === this.#client.user?.id && msg.id !== latest?.id);
for (const [ , msg ] of filtered)
{
if (msg.deletable && (msg.embeds.length || msg.content.startsWith('Now playing')))
await msg.delete();
}
for (const [ , msg ] of filtered)
{
if (msg.deletable && (msg.embeds.length || msg.content.startsWith('Now playing')))
await msg.delete();
}
});
}
get volume ()
@ -160,6 +201,12 @@ class MusicPlayer implements Initialisable
connection.disconnect();
}
this.#library.stop();
const config = {
volume: this.#volume,
queue: this.#queue
};
fs.writeFileSync(cachePath, JSON.stringify(config));
}
async initialise ()
@ -168,6 +215,13 @@ class MusicPlayer implements Initialisable
return;
this.#logger.info('Initialising music player');
if (fs.existsSync(cachePath))
{
const rawConfig = fs.readFileSync(cachePath, { encoding: 'utf-8' });
const config = JSON.parse(rawConfig);
this.#volume = config.volume;
this.#queue = config.queue;
}
await this.#library.initialise();
this.#shuffleList = this.#library.getShufflePlaylist();

View File

@ -9,6 +9,7 @@ import { isInitialisable } from '../../interfaces/Initialisable.js';
import Command from '../../interfaces/Command.js';
import Inhibitor from '../../interfaces/Inhibitor.js';
import Observer from '../../interfaces/Observer.js';
import Downloader from '../../interfaces/Downloader.js';
class Registry
{
@ -124,6 +125,11 @@ class Registry
return this.#components.filter(comp => comp.type === 'inhibitor') as Collection<string, Inhibitor>;
}
get downloaders ()
{
return this.#components.filter(comp => comp.type === 'downloader') as Collection<string, Downloader>;
}
get components ()
{
return this.#components.clone();

View File

@ -0,0 +1,46 @@
import assert from 'node:assert';
import { Message } from 'discord.js';
import { CommandOpts } from '@navy.gif/commandparser';
import Command from '../../../interfaces/Command.js';
import DiscordClient from '../../DiscordClient.js';
import MusicPlayerError from '../../../errors/MusicPlayerError.js';
class RequestCommand extends Command
{
constructor (client: DiscordClient)
{
super(client, {
name: 'request',
guildOnly: true,
sameVc: true,
showUsage: true,
options: [{
name: 'link'
}]
});
}
async execute (message: Message, { args }: CommandOpts)
{
const { author } = message;
assert(args.link);
this.logger.info(`${author.displayName} (${author.id}) is requesting ${args.link.value}`);
const response = await message.reply('Processing request, song will be queued after download');
try
{
const result = await this.client.musicPlayer.request(args.link.value as string);
if (!result)
return response.edit('Failed to download song');
return response.edit(`Successfully downloaded and queued **${result.title}** by ${result.artist}`);
}
catch (err)
{
if (err instanceof MusicPlayerError)
return response.edit(`**Error while requesting song:**\n${err.message}`);
this.logger.error(err as Error);
return 'Internal error, this has been logged';
}
}
}
export default RequestCommand;

View File

@ -1,5 +1,7 @@
import { Message } from 'discord.js';
import Command from '../../../interfaces/Command.js';
import DiscordClient from '../../DiscordClient.js';
import { CommandOpts, OptionType } from '@navy.gif/commandparser';
class PingCommand extends Command
{
@ -7,13 +9,20 @@ class PingCommand extends Command
{
super(client, {
name: 'rescan',
restricted: true
restricted: true,
options: [{
name: 'rebuild',
type: OptionType.BOOLEAN,
flag: true,
valueOptional: true,
defaultValue: true
}]
});
}
async execute ()
async execute (_message: Message, { args }: CommandOpts)
{
const diff = await this.client.musicPlayer.library.scanLibrary();
const diff = await this.client.musicPlayer.library.scanLibrary(args.rebuild?.value as boolean);
const songs = this.client.musicPlayer.library.size;
return `Found ${songs} tracks with ${diff} new ones`;
}

View File

@ -0,0 +1,22 @@
import Command from '../../../interfaces/Command.js';
import DiscordClient from '../../DiscordClient.js';
class ReshuffleCommand extends Command
{
constructor (client: DiscordClient)
{
super(client, {
name: 'reshuffle',
sameVc: true,
guildOnly: true,
});
}
async execute ()
{
this.client.musicPlayer.reshuffle();
return 'Playlist shuffled';
}
}
export default ReshuffleCommand;

View File

@ -34,7 +34,7 @@ class SearchCommand extends Command
if (!results.length)
return 'No results found';
return `
**Search results:**\n${results.map(result => `\t\\- **${result.arist}** - ${result.title} (${result.album ?? 'Unknown album'}) [${result.year ?? 'Unknown year'}]`).join('\n')}
**Search results:**\n${results.map(result => `\t\\- [id: ${result.id}] **${result.artist}** - ${result.title} (${result.album ?? 'Unknown album'}) [${result.year ?? 'Unknown year'}]`).join('\n')}
`;
}
}

View File

@ -0,0 +1,112 @@
import childProcess from 'node:child_process';
import fs from 'node:fs';
import { DownloaderResult } from '../../../../@types/Downloader.js';
import Downloader from '../../../interfaces/Downloader.js';
import DiscordClient from '../../DiscordClient.js';
import path from 'node:path';
class SpotifyDownloader extends Downloader
{
#executable;
#available;
#options: string[];
constructor (client: DiscordClient)
{
super(client, {
name: 'spotify'
});
this.#executable = 'zotify';
this.#available = false;
this.#options = [
'--print-download-progress false',
'--download-lyrics false',
'--download-quality very_high',
'--download-real-time true',
'--md-allgenres true',
'--skip-previously-downloaded true',
'--output "{artist} - {song_name} - {album}.{ext}"',
'--print-downloads true',
];
}
override test (): void | Promise<void>
{
return new Promise((resolve, reject) =>
{
childProcess.exec(`"${this.#executable}" --help`, (error) =>
{
if (error)
{
this.#available = false;
return reject(error);
}
this.#available = true;
resolve();
});
});
}
override download (url: string): Promise<DownloaderResult>
{
if (!this.#available)
throw new Error('This downloader is not available, please install zotify on your system');
if (!this.downloadDir)
throw new Error('No download directory defined');
const match = url.match(/spotify\.com\/track\/(\w+)/u);
if (!match)
throw new Error('Bad URL');
const [ , id ] = match;
if (!id)
throw new Error('Could not parse ID from url');
this.logger.debug('Song ID', id);
return new Promise((resolve, reject) =>
{
childProcess.exec(`"${this.#executable}" --root-path "${this.downloadDir}" ${this.#options.join(' ')} ${url}`, (error, stdout, stderr) =>
{
if (error)
return reject(error);
const songIdsPath = path.join(this.downloadDir!, '.song_ids');
this.logger.debug('.song_ids path', songIdsPath);
if (!fs.existsSync(songIdsPath))
return reject(new Error('Something went wrong with the download'));
const file = fs.readFileSync(songIdsPath, { encoding: 'utf-8' });
// console.log(file);
const lines = file.split('\n');
const line = lines.find(ln => ln.startsWith(id))?.replace('\r', '');
this.logger.debug('Song entry', line ?? 'undefined');
if (!line)
throw new Error('Failed to find file reference');
const elements = line.split('\t');
const fileName = elements[elements.length - 1];
const filePath = path.join(this.downloadDir!, fileName);
const [ name, ext ] = fileName.split('.');
const [ artist, song, album ] = name.split('-');
if (stderr && !stderr.includes('charmap'))
this.logger.debug('stderr', stderr);
if (stdout)
this.logger.debug('stdout', stdout);
resolve({
filePath,
artist,
song,
album,
ext
});
});
});
}
override get available (): boolean
{
return this.#available;
}
}
export default SpotifyDownloader;

View File

@ -0,0 +1,4 @@
class MusicPlayerError extends Error
{}
export default MusicPlayerError;

View File

@ -0,0 +1,48 @@
import { DownloaderOptions } from '../../@types/DiscordClient.js';
import { DownloaderResult } from '../../@types/Downloader.js';
import DiscordClient from '../client/DiscordClient.js';
import Component from './Component.js';
abstract class Downloader extends Component
{
#logger;
#downloadDir?: string;
constructor (client: DiscordClient, options: DownloaderOptions)
{
super(client, {
...options,
type: 'downloader'
});
this.#logger = client.createLogger(this);
}
protected get logger ()
{
return this.#logger;
}
abstract get available(): boolean;
/**
* Test whether the downloader is available
* @date 3/27/2024 - 11:47:43 AM
*
* @abstract
* @returns {(Promise<void> | void)}
*/
abstract test(): Promise<void> | void;
abstract download(url: string): Promise<DownloaderResult>;
get downloadDir ()
{
return this.#downloadDir;
}
set downloadDir (val: string | undefined)
{
this.#downloadDir = val;
}
}
export default Downloader;

View File

@ -17,6 +17,7 @@ class Controller
#logger: MasterLogger;
#version: string;
#shardingOptions: ShardingOptions;
#exiting: boolean;
// #options: ControllerOptions;
// #readyAt!: number;
@ -49,6 +50,10 @@ class Controller
this.#version = version;
this.#ready = false;
this.#exiting = false;
process.on('SIGINT', this.shutdown.bind(this));
process.on('SIGTERM', this.shutdown.bind(this));
}
async build ()
@ -103,8 +108,6 @@ class Controller
}
}
process.on('SIGINT', this.shutdown.bind(this));
this.#logger.status(`Shards spawned, spawned ${this.#shards.size} shards. Took ${Date.now() - start} ms`);
this.#ready = true;
@ -233,7 +236,10 @@ class Controller
async shutdown ()
{
this.#logger.info('Received SIGINT, shutting down');
if (this.#exiting)
return;
this.#exiting = true;
this.#logger.info('Received SIGINT or SIGTERM, shutting down');
setTimeout(process.exit, 90_000);
const promises = this.#shards
.filter(shard => shard.ready)

View File

@ -1628,10 +1628,10 @@ __metadata:
languageName: node
linkType: hard
"@navy.gif/commandparser@npm:^1.6.5":
version: 1.6.5
resolution: "@navy.gif/commandparser@npm:1.6.5"
checksum: 10/f0bc838ab7785dea28a91ca07e96a52ca58596032fe0d92badda246e90e245987f2d00e5ea5776fa156033c7f6ffd6fe6e1f9fb7d58096c60d3f33f08510532c
"@navy.gif/commandparser@npm:^1.6.6":
version: 1.6.6
resolution: "@navy.gif/commandparser@npm:1.6.6"
checksum: 10/1020ef32bd3b2b2e75dbbbf4814829765ec354ccac1c980978b49ebf2a6bc94cc7031691d2aa275ad98781027aa5cabe7bede8aecbd2d5a3bf8a48440b8befdf
languageName: node
linkType: hard
@ -1842,7 +1842,7 @@ __metadata:
languageName: node
linkType: hard
"@types/node@npm:*":
"@types/node@npm:*, @types/node@npm:^20.11.30":
version: 20.11.30
resolution: "@types/node@npm:20.11.30"
dependencies:
@ -3577,7 +3577,7 @@ __metadata:
"@babel/preset-typescript": "npm:^7.24.1"
"@discordjs/opus": "npm:^0.9.0"
"@discordjs/voice": "npm:^0.16.1"
"@navy.gif/commandparser": "npm:^1.6.5"
"@navy.gif/commandparser": "npm:^1.6.6"
"@navy.gif/logger": "npm:^2.5.4"
"@navy.gif/timestring": "npm:^6.0.6"
"@types/babel__core": "npm:^7"
@ -3585,6 +3585,7 @@ __metadata:
"@types/common-tags": "npm:^1"
"@types/eslint": "npm:^8"
"@types/humanize-duration": "npm:^3"
"@types/node": "npm:^20.11.30"
"@types/similarity": "npm:^1"
"@typescript-eslint/eslint-plugin": "npm:^7.3.1"
"@typescript-eslint/parser": "npm:^7.3.1"