Queue & Search commands

General improvements to everything else
This commit is contained in:
Erik 2024-03-25 21:22:24 +02:00
parent 699ba21b88
commit 2e291c514b
14 changed files with 342 additions and 15 deletions

View File

@ -35,6 +35,7 @@ export type CommandDefinition = {
guildOnly?: boolean, guildOnly?: boolean,
help?: string, help?: string,
limited?: Snowflake[], limited?: Snowflake[],
showUsage?: boolean
} & Omit<ComponentOptions, 'type'> } & Omit<ComponentOptions, 'type'>
export type ObserverOptions = { export type ObserverOptions = {

View File

@ -15,4 +15,19 @@ export type MusicIndexEntry = {
album?: string, album?: string,
year?: number, year?: number,
file: string, file: string,
stats: {
plays: number,
skips: number
}
}
export type MusicQuery = {
title?: string
artist?: string,
keyword?: string
}
export type QueueOrder = {
artist?: string,
title: string
} }

View File

@ -22,11 +22,13 @@
"@navy.gif/logger": "^2.5.4", "@navy.gif/logger": "^2.5.4",
"@navy.gif/timestring": "^6.0.6", "@navy.gif/timestring": "^6.0.6",
"bufferutil": "^4.0.8", "bufferutil": "^4.0.8",
"common-tags": "^1.8.2",
"discord.js": "^14.14.1", "discord.js": "^14.14.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"ffmpeg": "^0.0.4", "ffmpeg": "^0.0.4",
"humanize-duration": "^3.31.0", "humanize-duration": "^3.31.0",
"music-metadata": "^7.14.0", "music-metadata": "^7.14.0",
"similarity": "^1.2.1",
"sodium-native": "^4.1.1", "sodium-native": "^4.1.1",
"typescript": "^5.4.3", "typescript": "^5.4.3",
"utf-8-validate": "^6.0.3", "utf-8-validate": "^6.0.3",
@ -38,8 +40,10 @@
"@babel/preset-typescript": "^7.24.1", "@babel/preset-typescript": "^7.24.1",
"@types/babel__core": "^7", "@types/babel__core": "^7",
"@types/babel__preset-env": "^7", "@types/babel__preset-env": "^7",
"@types/common-tags": "^1",
"@types/eslint": "^8", "@types/eslint": "^8",
"@types/humanize-duration": "^3", "@types/humanize-duration": "^3",
"@types/similarity": "^1",
"@typescript-eslint/eslint-plugin": "^7.3.1", "@typescript-eslint/eslint-plugin": "^7.3.1",
"@typescript-eslint/parser": "^7.3.1", "@typescript-eslint/parser": "^7.3.1",
"eslint": "^8.57.0" "eslint": "^8.57.0"

View File

@ -7,8 +7,8 @@ import Util from '../../utilities/Util.js';
import Initialisable from '../../interfaces/Initialisable.js'; import Initialisable from '../../interfaces/Initialisable.js';
import DiscordClient from '../DiscordClient.js'; import DiscordClient from '../DiscordClient.js';
import { Collection } from 'discord.js'; import { Collection } from 'discord.js';
import { MusicIndexEntry } from '../../../@types/MusicPlayer.js'; import { MusicIndexEntry, MusicQuery } from '../../../@types/MusicPlayer.js';
import similarity from 'similarity';
class MusicLibrary implements Initialisable class MusicLibrary implements Initialisable
{ {
@ -47,7 +47,43 @@ class MusicLibrary implements Initialisable
stop (): void | Promise<void> stop (): void | Promise<void>
{ {
throw new Error('Method not implemented.'); this.saveIndex();
}
search (query: MusicQuery)
{
if (!Object.keys(query).length)
throw new Error('Invalid query');
const results: MusicIndexEntry[] = [];
for (const entry of this.#index.values())
{
if (query.artist && !entry.arist.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()))
continue;
results.push(entry);
}
return results.sort((a, b) =>
{
if (query.artist)
{
if (similarity(a.arist, query.artist) > similarity(b.arist, query.artist))
return 1;
return -1;
}
if (query.title)
{
if (similarity(a.title, query.title) > similarity(b.title, query.title))
return 1;
return -1;
}
return 0;
});
} }
getRandom () getRandom ()
@ -61,6 +97,18 @@ class MusicLibrary implements Initialisable
return Util.shuffle(entries); return Util.shuffle(entries);
} }
countPlay (fp: string)
{
if (this.#index.has(fp))
this.#index.get(fp)!.stats.plays++;
}
countSkip (fp: string)
{
if (this.#index.has(fp))
this.#index.get(fp)!.stats.skips++;
}
async scanLibrary (full = false) async scanLibrary (full = false)
{ {
this.#logger.info('Starting library scan'); this.#logger.info('Starting library scan');
@ -95,13 +143,17 @@ class MusicLibrary implements Initialisable
title: common.title ?? title, title: common.title ?? title,
album: common.album, album: common.album,
year: common.year, year: common.year,
file: fp file: fp,
stats: {
plays: 0,
skips: 0
}
}; };
this.#index.set(fp, entry); this.#index.set(fp, entry);
} }
const end = Date.now(); const end = Date.now();
const newFiles = this.#index.size - initialSize; const newFiles = this.#index.size - initialSize;
this.#logger.info(`Library scan took ${end - start} ms, ${this.#index.size} (new ${newFiles}) files indexed`); this.#logger.info(`Library scan took ${end - start} ms, ${this.#index.size} (${newFiles} new) files indexed`);
this.saveIndex(); this.saveIndex();
return newFiles; return newFiles;
} }
@ -115,7 +167,12 @@ class MusicLibrary implements Initialisable
const raw = fs.readFileSync(indexPath, { encoding: 'utf-8' }); const raw = fs.readFileSync(indexPath, { encoding: 'utf-8' });
const parsed = JSON.parse(raw) as MusicIndexEntry[]; const parsed = JSON.parse(raw) as MusicIndexEntry[];
for (const entry of parsed) for (const entry of parsed)
{
if (typeof entry.stats === 'undefined')
entry.stats = { plays: 0, skips: 0 };
this.#index.set(entry.file, entry); this.#index.set(entry.file, entry);
}
this.#logger.info(`Index loaded with ${this.#index.size} entries`);
} }
saveIndex () saveIndex ()

View File

@ -4,7 +4,7 @@ import { LoggerClient } from '@navy.gif/logger';
import Initialisable from '../../interfaces/Initialisable.js'; import Initialisable from '../../interfaces/Initialisable.js';
import DiscordClient from '../DiscordClient.js'; import DiscordClient from '../DiscordClient.js';
import { MusicIndexEntry, MusicPlayerOptions } from '../../../@types/MusicPlayer.js'; import { MusicIndexEntry, MusicPlayerOptions, QueueOrder } from '../../../@types/MusicPlayer.js';
import MusicLibrary from './MusicLibrary.js'; import MusicLibrary from './MusicLibrary.js';
type ConnectionDetails = { type ConnectionDetails = {
@ -27,9 +27,13 @@ class MusicPlayer implements Initialisable
#options: MusicPlayerOptions; #options: MusicPlayerOptions;
#volume: number; #volume: number;
#library: MusicLibrary; #library: MusicLibrary;
#shuffleIdx: number; #shuffleIdx: number;
#shuffleList: MusicIndexEntry[]; #shuffleList: MusicIndexEntry[];
#queue: MusicIndexEntry[];
#currentSong: MusicIndexEntry | null;
constructor (client: DiscordClient, options: MusicPlayerOptions) constructor (client: DiscordClient, options: MusicPlayerOptions)
{ {
this.#client = client; this.#client = client;
@ -45,6 +49,8 @@ class MusicPlayer implements Initialisable
this.#library = new MusicLibrary(client, this.#options.library); this.#library = new MusicLibrary(client, this.#options.library);
this.#shuffleList = []; this.#shuffleList = [];
this.#shuffleIdx = 0; this.#shuffleIdx = 0;
this.#queue = [];
this.#currentSong = null;
this.#player.on(AudioPlayerStatus.Idle, this.playNext.bind(this)); this.#player.on(AudioPlayerStatus.Idle, this.playNext.bind(this));
} }
@ -59,10 +65,30 @@ class MusicPlayer implements Initialisable
return this.#library; return this.#library;
} }
queue (order: QueueOrder)
{
const [ result ] = this.library.search(order);
if (!result)
return null;
this.#queue.push(result);
return result;
}
playNext () playNext ()
{ {
const info = this.#shuffleList[this.#shuffleIdx]; if (!this.#ready)
return;
if (this.#player.state.status !== AudioPlayerStatus.Idle && this.#currentSong)
this.library.countSkip(this.#currentSong.file);
let info: MusicIndexEntry | null = null;
if (this.#queue.length)
info = this.#queue.shift()!;
else
info = this.#shuffleList[this.#shuffleIdx];
this.#currentSong = info;
this.#currentResource = createAudioResource(info.file, { this.#currentResource = createAudioResource(info.file, {
inlineVolume: true, inlineVolume: true,
metadata: { metadata: {
@ -73,11 +99,16 @@ class MusicPlayer implements Initialisable
this.#logger.info(`Now playing ${info.arist} - ${info.title}`); this.#logger.info(`Now playing ${info.arist} - ${info.title}`);
this.#player.play(this.#currentResource); this.#player.play(this.#currentResource);
this.#library.countPlay(info.file);
this.#shuffleIdx++; this.#shuffleIdx++;
if (this.#shuffleIdx === this.#shuffleList.length)
this.#shuffleIdx = 0;
this.#client.user?.setPresence({ this.#client.user?.setPresence({
activities: [{ activities: [{
name: `${info.arist} - ${info.title}`, name: `${info.arist} - ${info.title}`,
type: ActivityType.Playing type: ActivityType.Listening
}] }]
}); });
@ -92,7 +123,7 @@ class MusicPlayer implements Initialisable
await channel.send({ await channel.send({
embeds: [{ embeds: [{
title: 'Now playing :notes:', title: 'Now playing :notes:',
description: `**${info.title}** by ${info.arist}`, description: `**${info!.title}** by ${info!.arist}`,
color: 0xffafff color: 0xffafff
}] }]
}); });
@ -119,6 +150,8 @@ class MusicPlayer implements Initialisable
stop (): void | Promise<void> stop (): void | Promise<void>
{ {
this.#ready = false;
this.#logger.info('Stopping music player');
this.#logger.info('Disconnecting all guilds'); this.#logger.info('Disconnecting all guilds');
for (const [ guildId, { connection, subscription }] of this.#connections) for (const [ guildId, { connection, subscription }] of this.#connections)
{ {
@ -126,6 +159,7 @@ class MusicPlayer implements Initialisable
subscription.unsubscribe(); subscription.unsubscribe();
connection.disconnect(); connection.disconnect();
} }
this.#library.stop();
} }
async initialise () async initialise ()
@ -140,6 +174,7 @@ class MusicPlayer implements Initialisable
this.initialiseVoiceChannels(); this.initialiseVoiceChannels();
this.#ready = true;
this.playNext(); this.playNext();
} }

View File

@ -0,0 +1,40 @@
import { Message } from 'discord.js';
import Command from '../../../interfaces/Command.js';
import DiscordClient from '../../DiscordClient.js';
import { CommandOpts } from '@navy.gif/commandparser';
class QueueCommand extends Command
{
constructor (client: DiscordClient)
{
super(client, {
name: 'queue',
showUsage: true,
options: [{
name: 'artist',
flag: true
}, {
name: 'song',
required: true
}]
});
}
async execute (message: Message<true>, { args }: CommandOpts)
{
const { member, guild } = message;
const { me } = guild.members;
if (!member?.voice || member.voice.channelId !== me?.voice.channelId)
return 'Only vc participants can queue songs';
const query = {
title: args.song!.value as string,
artist: args.artist?.value as string | undefined,
};
const result = this.client.musicPlayer.queue(query);
if (!result)
return 'Query yielded no results';
return `Song **${result.title}** by ${result.arist} queued`;
}
}
export default QueueCommand;

View File

@ -13,7 +13,7 @@ class PingCommand extends Command
async execute () async execute ()
{ {
const diff = this.client.musicPlayer.library.scanLibrary(); const diff = await this.client.musicPlayer.library.scanLibrary();
const songs = this.client.musicPlayer.library.size; const songs = this.client.musicPlayer.library.size;
return `Found ${songs} tracks with ${diff} new ones`; return `Found ${songs} tracks with ${diff} new ones`;
} }

View File

@ -0,0 +1,42 @@
import { Message } from 'discord.js';
import Command from '../../../interfaces/Command.js';
import DiscordClient from '../../DiscordClient.js';
import { CommandOpts } from '@navy.gif/commandparser';
class SearchCommand extends Command
{
constructor (client: DiscordClient)
{
super(client, {
name: 'search',
showUsage: true,
options: [{
name: 'keyword',
}, {
name: 'artist',
flag: true
}, {
name: 'song',
flag: true
}]
});
}
async execute (_message: Message, { args }: CommandOpts)
{
const query = {
title: args.song?.value as string | undefined,
artist: args.artist?.value as string | undefined,
keyword: args.keyword?.value as string | undefined
};
const results = this.client.musicPlayer.library.search(query);
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')}
`;
}
}
export default SearchCommand;

View File

@ -0,0 +1,41 @@
import { ArgsResult, CommandOpts, OptionType } from '@navy.gif/commandparser';
import Command from '../../../interfaces/Command.js';
import DiscordClient from '../../DiscordClient.js';
import { Message } from 'discord.js';
class SetAvatarCommand extends Command
{
constructor (client: DiscordClient)
{
super(client, {
name: 'set',
options: [{
name: 'avatar',
type: OptionType.SUB_COMMAND,
options: [{
name: 'asset',
required: true
}]
}],
restricted: true
});
}
async execute (message: Message, { subcommand, args }: CommandOpts)
{
if (subcommand === 'avatar')
return this.#setAvatar(message, args);
return 'Unknown subcommand';
}
async #setAvatar (_message: Message, args : ArgsResult)
{
const { asset } = args;
if (!asset?.value)
return 'Missing value';
await this.client.user?.setAvatar(asset.value as string);
return 'Avatar successfully set';
}
}
export default SetAvatarCommand;

View File

@ -1,3 +1,4 @@
import { Message } from 'discord.js';
import Command from '../../../interfaces/Command.js'; import Command from '../../../interfaces/Command.js';
import DiscordClient from '../../DiscordClient.js'; import DiscordClient from '../../DiscordClient.js';
@ -7,12 +8,19 @@ class SkipCommand extends Command
{ {
super(client, { super(client, {
name: 'skip', name: 'skip',
guildOnly: true guildOnly: true,
restricted: true
}); });
} }
async execute () async execute (message: Message<true>)
{ {
const { member, author, guild } = message;
const { me } = guild.members;
if (!member?.voice || member.voice.channelId !== me?.voice.channelId)
return 'Only vc participants can adjust volume';
this.logger.info(`${author.username} (${author.id}) skipped a song`);
this.client.musicPlayer.playNext(); this.client.musicPlayer.playNext();
return 'Song skipped'; return 'Song skipped';
} }

View File

@ -9,7 +9,6 @@ class VolumeCommand extends Command
{ {
super(client, { super(client, {
name: 'volume', name: 'volume',
aliases: [ 'v' ],
options: [ options: [
{ {
name: 'volume', name: 'volume',
@ -19,7 +18,8 @@ class VolumeCommand extends Command
maximum: 100 maximum: 100
} }
], ],
guildOnly: true guildOnly: true,
restricted: true
}); });
} }

View File

@ -1,7 +1,7 @@
import { inspect } from 'node:util'; import { inspect } from 'node:util';
import { APIEmbed, ChannelType, DiscordAPIError, EmbedBuilder, Events, Message, MessagePayload } from 'discord.js'; import { APIEmbed, ChannelType, DiscordAPIError, EmbedBuilder, Events, Message, MessagePayload } from 'discord.js';
import { CommandOpts, ICommand, Parser, ParserError } from '@navy.gif/commandparser'; import { CommandOpts, ICommand, OptionType, Parser, ParserError } from '@navy.gif/commandparser';
import Observer from '../../../interfaces/Observer.js'; import Observer from '../../../interfaces/Observer.js';
import DiscordClient from '../../DiscordClient.js'; import DiscordClient from '../../DiscordClient.js';
@ -9,6 +9,7 @@ import { InhibitorResponse } from '../../../../@types/DiscordClient.js';
import Command from '../../../interfaces/Command.js'; import Command from '../../../interfaces/Command.js';
import CommandError from '../../../errors/CommandError.js'; import CommandError from '../../../errors/CommandError.js';
import Util from '../../../utilities/Util.js'; import Util from '../../../utilities/Util.js';
import { stripIndents } from 'common-tags';
class CommandHandler extends Observer class CommandHandler extends Observer
@ -119,6 +120,9 @@ class CommandHandler extends Observer
}); });
} }
if ((command as Command).showUsage && !Object.keys(rest.args).length && !rest.subcommand && !rest.subcommandGroup)
return this.#showUsage(message, command as Command);
this.#executeCommand(message, command, rest); this.#executeCommand(message, command, rest);
} }
@ -220,6 +224,34 @@ class CommandHandler extends Observer
} }
} }
#showUsage (message: Message<boolean>, command: Command): void | PromiseLike<void>
{
const { options } = command;
const flags = options.filter(option => option.flag);
const nonFlags = options.filter(option => !option.flag);
let output = stripIndents`
USAGE: \`${this.client.prefix}${command.name} [OPTIONS] [FLAGS]\`
`;
if (nonFlags.length)
{
output += '\n\n' + stripIndents`
OPTIONS:
${nonFlags.map(opt => `\t \\- ${opt.name} (${OptionType[opt.type]})`).join('\n')}
`;
}
if (flags.length)
{
output += '\n\n' + stripIndents`
FLAGS:
${flags.map(flag => `\t \\- ${flag.name} (${OptionType[flag.type]})`).join('\n')}
`;
}
message.reply(output);
}
} }
export default CommandHandler; export default CommandHandler;

View File

@ -18,6 +18,7 @@ abstract class Command extends Component implements ICommand
#guildOnly: boolean; #guildOnly: boolean;
#dmOnly: boolean; #dmOnly: boolean;
#limited: Snowflake[] | null; // Limited to specific roles #limited: Snowflake[] | null; // Limited to specific roles
#showUsage: boolean;
constructor (client: DiscordClient, def: CommandDefinition) constructor (client: DiscordClient, def: CommandDefinition)
{ {
@ -33,6 +34,7 @@ abstract class Command extends Component implements ICommand
this.#guildOnly = def.guildOnly ?? false; this.#guildOnly = def.guildOnly ?? false;
this.#dmOnly = def.dmOnly ?? false; this.#dmOnly = def.dmOnly ?? false;
this.#limited = def.limited ?? null; this.#limited = def.limited ?? null;
this.#showUsage = def.showUsage ?? false;
this.#options = []; this.#options = [];
@ -67,6 +69,11 @@ abstract class Command extends Component implements ICommand
throw new CommandError(this, { reason: 'Command timed out', user }); throw new CommandError(this, { reason: 'Command timed out', user });
} }
get showUsage ()
{
return this.#showUsage;
}
get restricted () get restricted ()
{ {
return this.#restricted; return this.#restricted;

View File

@ -1804,6 +1804,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/common-tags@npm:^1":
version: 1.8.4
resolution: "@types/common-tags@npm:1.8.4"
checksum: 10/40c95a2f6388beb1cdeed3c9986ac0d6a3a551fce706e3e364a00ded48ab624b06b1ac8b94679bb2da9653e5eb3e450bad26873f5189993a5d8e8bdace74cbb2
languageName: node
linkType: hard
"@types/eslint@npm:^8": "@types/eslint@npm:^8":
version: 8.56.6 version: 8.56.6
resolution: "@types/eslint@npm:8.56.6" resolution: "@types/eslint@npm:8.56.6"
@ -1851,6 +1858,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/similarity@npm:^1":
version: 1.2.3
resolution: "@types/similarity@npm:1.2.3"
checksum: 10/1f3c9ad6e803e3c1d161c6701da09686a86a8772703765b259d3614ab1b0a84294b0b9f9044d596f7310cf583cc8614e1b1699f3e22175ba4af01fd3b295804e
languageName: node
linkType: hard
"@types/ws@npm:8.5.9": "@types/ws@npm:8.5.9":
version: 8.5.9 version: 8.5.9
resolution: "@types/ws@npm:8.5.9" resolution: "@types/ws@npm:8.5.9"
@ -2353,6 +2367,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"common-tags@npm:^1.8.2":
version: 1.8.2
resolution: "common-tags@npm:1.8.2"
checksum: 10/c665d0f463ee79dda801471ad8da6cb33ff7332ba45609916a508ad3d77ba07ca9deeb452e83f81f24c2b081e2c1315347f23d239210e63d1c5e1a0c7c019fe2
languageName: node
linkType: hard
"concat-map@npm:0.0.1": "concat-map@npm:0.0.1":
version: 0.0.1 version: 0.0.1
resolution: "concat-map@npm:0.0.1" resolution: "concat-map@npm:0.0.1"
@ -3251,6 +3272,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"levenshtein-edit-distance@npm:^2.0.0":
version: 2.0.5
resolution: "levenshtein-edit-distance@npm:2.0.5"
bin:
levenshtein-edit-distance: cli.js
checksum: 10/50618c01cd0c9bae6d4371d75af62c17c25a8f91bfd8d06400315b8b15976900cff951b48e102e074e9c5c6758260fff1675cfad186732afe124a5708e1032fd
languageName: node
linkType: hard
"levn@npm:^0.4.1": "levn@npm:^0.4.1":
version: 0.4.1 version: 0.4.1
resolution: "levn@npm:0.4.1" resolution: "levn@npm:0.4.1"
@ -3552,17 +3582,21 @@ __metadata:
"@navy.gif/timestring": "npm:^6.0.6" "@navy.gif/timestring": "npm:^6.0.6"
"@types/babel__core": "npm:^7" "@types/babel__core": "npm:^7"
"@types/babel__preset-env": "npm:^7" "@types/babel__preset-env": "npm:^7"
"@types/common-tags": "npm:^1"
"@types/eslint": "npm:^8" "@types/eslint": "npm:^8"
"@types/humanize-duration": "npm:^3" "@types/humanize-duration": "npm:^3"
"@types/similarity": "npm:^1"
"@typescript-eslint/eslint-plugin": "npm:^7.3.1" "@typescript-eslint/eslint-plugin": "npm:^7.3.1"
"@typescript-eslint/parser": "npm:^7.3.1" "@typescript-eslint/parser": "npm:^7.3.1"
bufferutil: "npm:^4.0.8" bufferutil: "npm:^4.0.8"
common-tags: "npm:^1.8.2"
discord.js: "npm:^14.14.1" discord.js: "npm:^14.14.1"
dotenv: "npm:^16.4.5" dotenv: "npm:^16.4.5"
eslint: "npm:^8.57.0" eslint: "npm:^8.57.0"
ffmpeg: "npm:^0.0.4" ffmpeg: "npm:^0.0.4"
humanize-duration: "npm:^3.31.0" humanize-duration: "npm:^3.31.0"
music-metadata: "npm:^7.14.0" music-metadata: "npm:^7.14.0"
similarity: "npm:^1.2.1"
sodium-native: "npm:^4.1.1" sodium-native: "npm:^4.1.1"
typescript: "npm:^5.4.3" typescript: "npm:^5.4.3"
utf-8-validate: "npm:^6.0.3" utf-8-validate: "npm:^6.0.3"
@ -4078,6 +4112,17 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"similarity@npm:^1.2.1":
version: 1.2.1
resolution: "similarity@npm:1.2.1"
dependencies:
levenshtein-edit-distance: "npm:^2.0.0"
bin:
similarity: cli.js
checksum: 10/7010abfb53ea72fcecb3b9f59e0753f589256b8a134886a5282cd1ee21226fdc7f11bfe45f673631928d8ae88283f23e6b6f5c4e84ab3f15d175b2d520e1bffe
languageName: node
linkType: hard
"slash@npm:^3.0.0": "slash@npm:^3.0.0":
version: 3.0.0 version: 3.0.0
resolution: "slash@npm:3.0.0" resolution: "slash@npm:3.0.0"