Refactor library into separate class, takes care of building track index, and future plans
This commit is contained in:
parent
38326a1fce
commit
699ba21b88
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,5 +1,6 @@
|
||||
node_modules
|
||||
|
||||
cache
|
||||
build
|
||||
logs
|
||||
|
||||
|
8
@types/MusicPlayer.d.ts
vendored
8
@types/MusicPlayer.d.ts
vendored
@ -8,3 +8,11 @@ export type MusicPlayerOptions = {
|
||||
library: string,
|
||||
guilds: GuildConfig[]
|
||||
}
|
||||
|
||||
export type MusicIndexEntry = {
|
||||
arist: string,
|
||||
title: string,
|
||||
album?: string,
|
||||
year?: number,
|
||||
file: string,
|
||||
}
|
@ -26,6 +26,7 @@
|
||||
"dotenv": "^16.4.5",
|
||||
"ffmpeg": "^0.0.4",
|
||||
"humanize-duration": "^3.31.0",
|
||||
"music-metadata": "^7.14.0",
|
||||
"sodium-native": "^4.1.1",
|
||||
"typescript": "^5.4.3",
|
||||
"utf-8-validate": "^6.0.3",
|
||||
|
@ -95,7 +95,7 @@ class DiscordClient extends Client
|
||||
this.#built = true;
|
||||
this.emit('built');
|
||||
|
||||
await this.#musicPlayer.initialise();
|
||||
this.#musicPlayer.initialise();
|
||||
return this;
|
||||
}
|
||||
|
||||
|
133
src/client/components/MusicLibrary.ts
Normal file
133
src/client/components/MusicLibrary.ts
Normal file
@ -0,0 +1,133 @@
|
||||
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { LoggerClient } from '@navy.gif/logger';
|
||||
import { parseFile } from 'music-metadata';
|
||||
import Util from '../../utilities/Util.js';
|
||||
import Initialisable from '../../interfaces/Initialisable.js';
|
||||
import DiscordClient from '../DiscordClient.js';
|
||||
import { Collection } from 'discord.js';
|
||||
import { MusicIndexEntry } from '../../../@types/MusicPlayer.js';
|
||||
|
||||
|
||||
class MusicLibrary implements Initialisable
|
||||
{
|
||||
#path: string;
|
||||
#ready: boolean;
|
||||
#index: Collection<string, MusicIndexEntry>;
|
||||
#logger: LoggerClient;
|
||||
constructor (client: DiscordClient, libraryPath: string)
|
||||
{
|
||||
this.#path = libraryPath;
|
||||
this.#ready = false;
|
||||
this.#index = new Collection();
|
||||
this.#logger = client.createLogger(this);
|
||||
}
|
||||
|
||||
get ready ()
|
||||
{
|
||||
return this.#ready;
|
||||
}
|
||||
|
||||
get size ()
|
||||
{
|
||||
return this.#index.size;
|
||||
}
|
||||
|
||||
async initialise (): Promise<void>
|
||||
{
|
||||
if (this.ready)
|
||||
return;
|
||||
this.#logger.info('Initialising music library');
|
||||
this.loadIndex();
|
||||
await this.scanLibrary();
|
||||
this.#ready = true;
|
||||
this.#logger.info(`Music library initialised with ${this.#index.size} entries`);
|
||||
}
|
||||
|
||||
stop (): void | Promise<void>
|
||||
{
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
getRandom ()
|
||||
{
|
||||
return this.#index.random();
|
||||
}
|
||||
|
||||
getShufflePlaylist ()
|
||||
{
|
||||
const entries = [ ...this.#index.values() ];
|
||||
return Util.shuffle(entries);
|
||||
}
|
||||
|
||||
async scanLibrary (full = false)
|
||||
{
|
||||
this.#logger.info('Starting library scan');
|
||||
const start = Date.now();
|
||||
const filePaths = Util.readdirRecursive(this.#path);
|
||||
if (!this.#index.size)
|
||||
this.#logger.info('No index built, performing first time scan. This may take some time depending on the size of your music library');
|
||||
|
||||
const initialSize = this.#index.size;
|
||||
let idx = 0;
|
||||
for (const fp of filePaths)
|
||||
{
|
||||
// Progress reporting
|
||||
idx++;
|
||||
if (idx % 50 === 0 || idx === filePaths.length)
|
||||
this.#logger.info(`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
|
||||
};
|
||||
this.#index.set(fp, entry);
|
||||
}
|
||||
const end = Date.now();
|
||||
const newFiles = this.#index.size - initialSize;
|
||||
this.#logger.info(`Library scan took ${end - start} ms, ${this.#index.size} (new ${newFiles}) files indexed`);
|
||||
this.saveIndex();
|
||||
return newFiles;
|
||||
}
|
||||
|
||||
loadIndex ()
|
||||
{
|
||||
this.#logger.info('Loading index');
|
||||
const indexPath = path.join(process.cwd(), 'cache', 'musicIndex.json');
|
||||
if (!fs.existsSync(indexPath))
|
||||
return this.#logger.info('No index file found');
|
||||
const raw = fs.readFileSync(indexPath, { encoding: 'utf-8' });
|
||||
const parsed = JSON.parse(raw) as MusicIndexEntry[];
|
||||
for (const entry of parsed)
|
||||
this.#index.set(entry.file, entry);
|
||||
}
|
||||
|
||||
saveIndex ()
|
||||
{
|
||||
this.#logger.info('Saving index to file');
|
||||
const cachePath = path.join(process.cwd(), 'cache');
|
||||
if (!fs.existsSync(cachePath))
|
||||
fs.mkdirSync(cachePath);
|
||||
const indexPath = path.join(cachePath, 'musicIndex.json');
|
||||
fs.writeFileSync(indexPath, JSON.stringify([ ...this.#index.values() ]));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default MusicLibrary;
|
@ -1,13 +1,11 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { ActivityType, ChannelType, Collection, GuildTextBasedChannel } from 'discord.js';
|
||||
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';
|
||||
|
||||
import Initialisable from '../../interfaces/Initialisable.js';
|
||||
import DiscordClient from '../DiscordClient.js';
|
||||
import Util from '../../utilities/Util.js';
|
||||
import { MusicPlayerOptions } from '../../../@types/MusicPlayer.js';
|
||||
import { MusicIndexEntry, MusicPlayerOptions } from '../../../@types/MusicPlayer.js';
|
||||
import MusicLibrary from './MusicLibrary.js';
|
||||
|
||||
type ConnectionDetails = {
|
||||
subscription: PlayerSubscription,
|
||||
@ -24,13 +22,13 @@ class MusicPlayer implements Initialisable
|
||||
|
||||
#connections: Collection<string, ConnectionDetails>;
|
||||
#player: AudioPlayer;
|
||||
|
||||
#library: string[];
|
||||
#currentIdx: number;
|
||||
#currentResource!: AudioResource;
|
||||
|
||||
#options: MusicPlayerOptions;
|
||||
#volume: number;
|
||||
#library: MusicLibrary;
|
||||
#shuffleIdx: number;
|
||||
#shuffleList: MusicIndexEntry[];
|
||||
|
||||
constructor (client: DiscordClient, options: MusicPlayerOptions)
|
||||
{
|
||||
@ -42,10 +40,12 @@ class MusicPlayer implements Initialisable
|
||||
this.#player = createAudioPlayer();
|
||||
|
||||
this.#options = options;
|
||||
this.#library = [];
|
||||
this.#currentIdx = 0;
|
||||
this.#volume = 0.3;
|
||||
|
||||
this.#library = new MusicLibrary(client, this.#options.library);
|
||||
this.#shuffleList = [];
|
||||
this.#shuffleIdx = 0;
|
||||
|
||||
this.#player.on(AudioPlayerStatus.Idle, this.playNext.bind(this));
|
||||
}
|
||||
|
||||
@ -54,28 +54,33 @@ class MusicPlayer implements Initialisable
|
||||
return this.#ready;
|
||||
}
|
||||
|
||||
get library ()
|
||||
{
|
||||
return this.#library;
|
||||
}
|
||||
|
||||
playNext ()
|
||||
{
|
||||
const uri = this.#library[this.#currentIdx];
|
||||
const segmets = uri.replace(this.#options.library, '').split(path.sep);
|
||||
const artist = segmets[segmets.length - 2];
|
||||
const title = segmets[segmets.length - 1].replace((/\..+$/u), '');
|
||||
const display = `[${artist ?? 'Unknown'}] ${title}`;
|
||||
this.#currentResource = createAudioResource(uri, {
|
||||
const info = this.#shuffleList[this.#shuffleIdx];
|
||||
|
||||
this.#currentResource = createAudioResource(info.file, {
|
||||
inlineVolume: true,
|
||||
metadata: {
|
||||
display
|
||||
} });
|
||||
title: info.title
|
||||
}
|
||||
});
|
||||
this.#currentResource.volume?.setVolume(this.#volume);
|
||||
this.#logger.info(`Now playing ${display}`);
|
||||
|
||||
this.#logger.info(`Now playing ${info.arist} - ${info.title}`);
|
||||
this.#player.play(this.#currentResource);
|
||||
this.#currentIdx++;
|
||||
this.#shuffleIdx++;
|
||||
this.#client.user?.setPresence({
|
||||
activities: [{
|
||||
name: display,
|
||||
name: `${info.arist} - ${info.title}`,
|
||||
type: ActivityType.Playing
|
||||
}]
|
||||
});
|
||||
|
||||
const outputChannels = this.#connections.map(obj => obj.textOutput);
|
||||
outputChannels.forEach(async channel =>
|
||||
{
|
||||
@ -84,11 +89,17 @@ class MusicPlayer implements Initialisable
|
||||
const messages = await channel.messages.fetch({ limit: 100 });
|
||||
const filtered = messages.filter(msg => msg.author.id === this.#client.user?.id);
|
||||
|
||||
await channel.send(`Now playing **${display}**`);
|
||||
// await channel.bulkDelete(filtered);
|
||||
await channel.send({
|
||||
embeds: [{
|
||||
title: 'Now playing :notes:',
|
||||
description: `**${info.title}** by ${info.arist}`,
|
||||
color: 0xffafff
|
||||
}]
|
||||
});
|
||||
|
||||
for (const [ , msg ] of filtered)
|
||||
{
|
||||
if (msg.deletable && msg.content.startsWith('Now playing'))
|
||||
if (msg.deletable && (msg.embeds.length || msg.content.startsWith('Now playing')))
|
||||
await msg.delete();
|
||||
}
|
||||
});
|
||||
@ -117,10 +128,23 @@ class MusicPlayer implements Initialisable
|
||||
}
|
||||
}
|
||||
|
||||
initialise ()
|
||||
async initialise ()
|
||||
{
|
||||
if (this.ready)
|
||||
return;
|
||||
this.#logger.info('Initialising music player');
|
||||
|
||||
await this.#library.initialise();
|
||||
|
||||
this.#shuffleList = this.#library.getShufflePlaylist();
|
||||
|
||||
this.initialiseVoiceChannels();
|
||||
|
||||
this.playNext();
|
||||
}
|
||||
|
||||
initialiseVoiceChannels ()
|
||||
{
|
||||
for (const config of this.#options.guilds)
|
||||
{
|
||||
const { id, voiceChannel, textOutput } = config;
|
||||
@ -137,6 +161,13 @@ class MusicPlayer implements Initialisable
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!this.joinVoiceChannel(guild, channel, textOutput))
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
joinVoiceChannel (guild: Guild, channel: VoiceChannel, output?: string)
|
||||
{
|
||||
const connection = joinVoiceChannel({
|
||||
channelId: channel.id,
|
||||
guildId: guild.id,
|
||||
@ -146,13 +177,13 @@ class MusicPlayer implements Initialisable
|
||||
if (!subscription)
|
||||
{
|
||||
connection.disconnect();
|
||||
continue;
|
||||
return false;
|
||||
}
|
||||
|
||||
let textChannel = null;
|
||||
if (textOutput)
|
||||
if (output)
|
||||
{
|
||||
textChannel = guild.channels.resolve(textOutput);
|
||||
textChannel = guild.channels.resolve(output);
|
||||
if (!textChannel?.isTextBased())
|
||||
{
|
||||
textChannel = null;
|
||||
@ -161,20 +192,7 @@ class MusicPlayer implements Initialisable
|
||||
}
|
||||
this.#connections.set(guild.id, { connection, subscription, textOutput: textChannel });
|
||||
this.#logger.info(`Connected to voice in ${guild.name}`);
|
||||
}
|
||||
|
||||
this.scanLibrary();
|
||||
this.playNext();
|
||||
}
|
||||
|
||||
scanLibrary ()
|
||||
{
|
||||
const current = this.#library.length;
|
||||
const library = Util.readdirRecursive(this.#options.library);
|
||||
this.#logger.info(`Scanned music library, found ${library.length} tracks`);
|
||||
this.#library = Util.shuffle(library);
|
||||
const after = this.#library.length;
|
||||
return [ after, after - current ];
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -13,7 +13,8 @@ class PingCommand extends Command
|
||||
|
||||
async execute ()
|
||||
{
|
||||
const [ songs, diff ] = this.client.musicPlayer.scanLibrary();
|
||||
const diff = this.client.musicPlayer.library.scanLibrary();
|
||||
const songs = this.client.musicPlayer.library.size;
|
||||
return `Found ${songs} tracks with ${diff} new ones`;
|
||||
}
|
||||
}
|
||||
|
@ -29,7 +29,7 @@ class CommandHandler extends Observer
|
||||
commands: this.client.commands.values(),
|
||||
prefix: client.prefix,
|
||||
resolver: client.resolver,
|
||||
debug: true
|
||||
debug: false
|
||||
});
|
||||
|
||||
this.#parser.on('debug', (str: string) => this.logger.debug(str));
|
||||
|
@ -559,6 +559,16 @@ class Util
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Shuffles array in place
|
||||
* @date 3/25/2024 - 11:25:09 AM
|
||||
*
|
||||
* @static
|
||||
* @template T
|
||||
* @param {T[]} array
|
||||
* @returns {T[]}
|
||||
*/
|
||||
static shuffle<T> (array: T[]): T[]
|
||||
{
|
||||
let current = array.length;
|
||||
|
91
yarn.lock
91
yarn.lock
@ -1749,6 +1749,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tokenizer/token@npm:^0.3.0":
|
||||
version: 0.3.0
|
||||
resolution: "@tokenizer/token@npm:0.3.0"
|
||||
checksum: 10/889c1f1e63ac7c92c0ea22d4a2861142f1b43c3d92eb70ec42aa9e9851fab2e9952211d50f541b287781280df2f979bf5600a9c1f91fbc61b7fcf9994e9376a5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/babel__core@npm:^7":
|
||||
version: 7.20.5
|
||||
resolution: "@types/babel__core@npm:7.20.5"
|
||||
@ -2360,6 +2367,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"content-type@npm:^1.0.5":
|
||||
version: 1.0.5
|
||||
resolution: "content-type@npm:1.0.5"
|
||||
checksum: 10/585847d98dc7fb8035c02ae2cb76c7a9bd7b25f84c447e5ed55c45c2175e83617c8813871b4ee22f368126af6b2b167df655829007b21aa10302873ea9c62662
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"convert-source-map@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "convert-source-map@npm:2.0.0"
|
||||
@ -2722,6 +2736,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"file-type@npm:^16.5.4":
|
||||
version: 16.5.4
|
||||
resolution: "file-type@npm:16.5.4"
|
||||
dependencies:
|
||||
readable-web-to-node-stream: "npm:^3.0.0"
|
||||
strtok3: "npm:^6.2.4"
|
||||
token-types: "npm:^4.1.1"
|
||||
checksum: 10/46ced46bb925ab547e0a6d43108a26d043619d234cb0588d7abce7b578dafac142bcfd2e23a6adb0a4faa4b951bd1b14b355134a193362e07cd352f9bf0dc349
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fill-range@npm:^7.0.1":
|
||||
version: 7.0.1
|
||||
resolution: "fill-range@npm:7.0.1"
|
||||
@ -2999,6 +3024,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ieee754@npm:^1.2.1":
|
||||
version: 1.2.1
|
||||
resolution: "ieee754@npm:1.2.1"
|
||||
checksum: 10/d9f2557a59036f16c282aaeb107832dc957a93d73397d89bbad4eb1130560560eb695060145e8e6b3b498b15ab95510226649a0b8f52ae06583575419fe10fc4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ignore@npm:^5.2.0, ignore@npm:^5.2.4":
|
||||
version: 5.3.1
|
||||
resolution: "ignore@npm:5.3.1"
|
||||
@ -3326,6 +3358,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"media-typer@npm:^1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "media-typer@npm:1.1.0"
|
||||
checksum: 10/a58dd60804df73c672942a7253ccc06815612326dc1c0827984b1a21704466d7cde351394f47649e56cf7415e6ee2e26e000e81b51b3eebb5a93540e8bf93cbd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"merge2@npm:^1.3.0, merge2@npm:^1.4.1":
|
||||
version: 1.4.1
|
||||
resolution: "merge2@npm:1.4.1"
|
||||
@ -3468,6 +3507,21 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"music-metadata@npm:^7.14.0":
|
||||
version: 7.14.0
|
||||
resolution: "music-metadata@npm:7.14.0"
|
||||
dependencies:
|
||||
"@tokenizer/token": "npm:^0.3.0"
|
||||
content-type: "npm:^1.0.5"
|
||||
debug: "npm:^4.3.4"
|
||||
file-type: "npm:^16.5.4"
|
||||
media-typer: "npm:^1.1.0"
|
||||
strtok3: "npm:^6.3.0"
|
||||
token-types: "npm:^4.2.1"
|
||||
checksum: 10/b6fbfb874e06a540439a8ed67e69c0a73866085e5a1304d5f3808de5b3f912132add13007fc2892114da6a7f72300a54821cc8fc5ae0383910b2a0d2ced61074
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"nan@npm:^2.18.0":
|
||||
version: 2.19.0
|
||||
resolution: "nan@npm:2.19.0"
|
||||
@ -3508,6 +3562,7 @@ __metadata:
|
||||
eslint: "npm:^8.57.0"
|
||||
ffmpeg: "npm:^0.0.4"
|
||||
humanize-duration: "npm:^3.31.0"
|
||||
music-metadata: "npm:^7.14.0"
|
||||
sodium-native: "npm:^4.1.1"
|
||||
typescript: "npm:^5.4.3"
|
||||
utf-8-validate: "npm:^6.0.3"
|
||||
@ -3728,6 +3783,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"peek-readable@npm:^4.1.0":
|
||||
version: 4.1.0
|
||||
resolution: "peek-readable@npm:4.1.0"
|
||||
checksum: 10/97373215dcf382748645c3d22ac5e8dbd31759f7bd0c539d9fdbaaa7d22021838be3e55110ad0ed8f241c489342304b14a50dfee7ef3bcee2987d003b24ecc41
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"picocolors@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "picocolors@npm:1.0.0"
|
||||
@ -3812,6 +3874,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"readable-web-to-node-stream@npm:^3.0.0":
|
||||
version: 3.0.2
|
||||
resolution: "readable-web-to-node-stream@npm:3.0.2"
|
||||
dependencies:
|
||||
readable-stream: "npm:^3.6.0"
|
||||
checksum: 10/d3a5bf9d707c01183d546a64864aa63df4d9cb835dfd2bf89ac8305e17389feef2170c4c14415a10d38f9b9bfddf829a57aaef7c53c8b40f11d499844bf8f1a4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"regenerate-unicode-properties@npm:^10.1.0":
|
||||
version: 10.1.1
|
||||
resolution: "regenerate-unicode-properties@npm:10.1.1"
|
||||
@ -4124,6 +4195,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"strtok3@npm:^6.2.4, strtok3@npm:^6.3.0":
|
||||
version: 6.3.0
|
||||
resolution: "strtok3@npm:6.3.0"
|
||||
dependencies:
|
||||
"@tokenizer/token": "npm:^0.3.0"
|
||||
peek-readable: "npm:^4.1.0"
|
||||
checksum: 10/98fba564d3830202aa3a6bcd5ccaf2cbd849bd87ae79ece91d337e1913916705a8e633c9577138d030a984f8ec987dea51807e01252f995cf5e183fdea35eb2b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"supports-color@npm:^5.3.0":
|
||||
version: 5.5.0
|
||||
resolution: "supports-color@npm:5.5.0"
|
||||
@ -4186,6 +4267,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"token-types@npm:^4.1.1, token-types@npm:^4.2.1":
|
||||
version: 4.2.1
|
||||
resolution: "token-types@npm:4.2.1"
|
||||
dependencies:
|
||||
"@tokenizer/token": "npm:^0.3.0"
|
||||
ieee754: "npm:^1.2.1"
|
||||
checksum: 10/2995257d246387e773758c3c92a3cc99d0c0bf13cbafe0de5d712e4c35ed298da6704e21545cb123fa1f1b42ad62936c35bbd0611018b735e78c30b8b22b42d9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tr46@npm:~0.0.3":
|
||||
version: 0.0.3
|
||||
resolution: "tr46@npm:0.0.3"
|
||||
|
Loading…
Reference in New Issue
Block a user