Bunch of updates and fixes
rebuild option in rescan request command to request songs music downloader classes misc fixes
This commit is contained in:
parent
671623dca7
commit
822bf608f3
@ -29,7 +29,8 @@
|
|||||||
"parser": "@typescript-eslint/parser",
|
"parser": "@typescript-eslint/parser",
|
||||||
"parserOptions": {
|
"parserOptions": {
|
||||||
"ecmaVersion": 2022,
|
"ecmaVersion": 2022,
|
||||||
"sourceType": "module"
|
"sourceType": "module",
|
||||||
|
"project": "./tsconfig.json"
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
"@typescript-eslint/no-unused-vars": "off",
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
|
7
@types/Downloader.d.ts
vendored
Normal file
7
@types/Downloader.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export type DownloaderResult = {
|
||||||
|
filePath: string,
|
||||||
|
artist: string,
|
||||||
|
song: string,
|
||||||
|
album: string,
|
||||||
|
ext: string
|
||||||
|
};
|
10
@types/MusicPlayer.d.ts
vendored
10
@types/MusicPlayer.d.ts
vendored
@ -10,10 +10,12 @@ export type MusicPlayerOptions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type MusicIndexEntry = {
|
export type MusicIndexEntry = {
|
||||||
arist: string,
|
id: number,
|
||||||
|
artist: string,
|
||||||
title: string,
|
title: string,
|
||||||
album?: string,
|
album?: string,
|
||||||
year?: number,
|
year?: number,
|
||||||
|
genre: string[]
|
||||||
file: string,
|
file: string,
|
||||||
stats: {
|
stats: {
|
||||||
plays: number,
|
plays: number,
|
||||||
@ -24,10 +26,12 @@ export type MusicIndexEntry = {
|
|||||||
export type MusicQuery = {
|
export type MusicQuery = {
|
||||||
title?: string
|
title?: string
|
||||||
artist?: string,
|
artist?: string,
|
||||||
keyword?: string
|
keyword?: string,
|
||||||
|
id?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type QueueOrder = {
|
export type QueueOrder = {
|
||||||
artist?: string,
|
artist?: string,
|
||||||
title: string
|
title?: string,
|
||||||
|
id?: number
|
||||||
}
|
}
|
@ -10,6 +10,9 @@ RUN yarn install
|
|||||||
|
|
||||||
FROM node:lts-alpine
|
FROM node:lts-alpine
|
||||||
RUN apk update && apk add ffmpeg
|
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
|
WORKDIR /musicbot
|
||||||
COPY build build
|
COPY build build
|
||||||
COPY --from=builder /musicbot/node_modules ./node_modules
|
COPY --from=builder /musicbot/node_modules ./node_modules
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
# music-bot
|
# music-bot
|
||||||
|
|
||||||
A bot that plays music on Discord
|
A bot that plays music on Discord using your local library
|
@ -14,15 +14,16 @@
|
|||||||
"debug": "node --trace-warnings --inspect index.js",
|
"debug": "node --trace-warnings --inspect index.js",
|
||||||
"lint": "eslint --fix src/",
|
"lint": "eslint --fix src/",
|
||||||
"build": "tsc --build",
|
"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"
|
"docker:build": "yarn build && docker build . --tag git.corgi.wtf/navy.gif/music-bot:latest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discordjs/opus": "^0.9.0",
|
"@discordjs/opus": "^0.9.0",
|
||||||
"@discordjs/voice": "^0.16.1",
|
"@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/logger": "^2.5.4",
|
||||||
"@navy.gif/timestring": "^6.0.6",
|
"@navy.gif/timestring": "^6.0.6",
|
||||||
|
"@types/node": "^20.11.30",
|
||||||
"bufferutil": "^4.0.8",
|
"bufferutil": "^4.0.8",
|
||||||
"common-tags": "^1.8.2",
|
"common-tags": "^1.8.2",
|
||||||
"discord.js": "^14.14.1",
|
"discord.js": "^14.14.1",
|
||||||
|
@ -12,6 +12,7 @@ import Command from '../interfaces/Command.js';
|
|||||||
import Resolver from './components/Resolver.js';
|
import Resolver from './components/Resolver.js';
|
||||||
import Inhibitor from '../interfaces/Inhibitor.js';
|
import Inhibitor from '../interfaces/Inhibitor.js';
|
||||||
import EventHooker from './components/EventHooker.js';
|
import EventHooker from './components/EventHooker.js';
|
||||||
|
import Downloader from '../interfaces/Downloader.js';
|
||||||
|
|
||||||
class DiscordClient extends Client
|
class DiscordClient extends Client
|
||||||
{
|
{
|
||||||
@ -68,6 +69,7 @@ class DiscordClient extends Client
|
|||||||
|
|
||||||
// process.on('message', this.#handleMessage.bind(this));
|
// process.on('message', this.#handleMessage.bind(this));
|
||||||
process.on('SIGINT', () => this.shutdown());
|
process.on('SIGINT', () => this.shutdown());
|
||||||
|
process.on('SIGTERM', () => this.shutdown());
|
||||||
|
|
||||||
this.#built = false;
|
this.#built = false;
|
||||||
}
|
}
|
||||||
@ -81,6 +83,7 @@ class DiscordClient extends Client
|
|||||||
await this.#registry.loadComponents('components/commands', Command);
|
await this.#registry.loadComponents('components/commands', Command);
|
||||||
await this.#registry.loadComponents('components/inhibitors', Inhibitor);
|
await this.#registry.loadComponents('components/inhibitors', Inhibitor);
|
||||||
await this.#registry.loadComponents('components/observers', Observer);
|
await this.#registry.loadComponents('components/observers', Observer);
|
||||||
|
await this.#registry.loadComponents('components/downloaders', Downloader);
|
||||||
|
|
||||||
this.#logger.info('Initialising events');
|
this.#logger.info('Initialising events');
|
||||||
await this.#eventHooker.init();
|
await this.#eventHooker.init();
|
||||||
|
80
src/client/components/MusicDownloader.ts
Normal file
80
src/client/components/MusicDownloader.ts
Normal 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;
|
@ -9,19 +9,26 @@ import DiscordClient from '../DiscordClient.js';
|
|||||||
import { Collection } from 'discord.js';
|
import { Collection } from 'discord.js';
|
||||||
import { MusicIndexEntry, MusicQuery } from '../../../@types/MusicPlayer.js';
|
import { MusicIndexEntry, MusicQuery } from '../../../@types/MusicPlayer.js';
|
||||||
import similarity from 'similarity';
|
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
|
class MusicLibrary implements Initialisable
|
||||||
{
|
{
|
||||||
#path: string;
|
#path: string;
|
||||||
#ready: boolean;
|
#ready: boolean;
|
||||||
#index: Collection<string, MusicIndexEntry>;
|
#index: Collection<string, MusicIndexEntry>;
|
||||||
#logger: LoggerClient;
|
#logger: LoggerClient;
|
||||||
|
#currentId: number;
|
||||||
|
#downloader: MusicDownloader;
|
||||||
constructor (client: DiscordClient, libraryPath: string)
|
constructor (client: DiscordClient, libraryPath: string)
|
||||||
{
|
{
|
||||||
this.#path = libraryPath;
|
this.#path = libraryPath;
|
||||||
this.#ready = false;
|
this.#ready = false;
|
||||||
this.#index = new Collection();
|
this.#index = new Collection();
|
||||||
|
this.#downloader = new MusicDownloader(client);
|
||||||
this.#logger = client.createLogger(this);
|
this.#logger = client.createLogger(this);
|
||||||
|
this.#currentId = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
get ready ()
|
get ready ()
|
||||||
@ -40,6 +47,7 @@ class MusicLibrary implements Initialisable
|
|||||||
return;
|
return;
|
||||||
this.#logger.info('Initialising music library');
|
this.#logger.info('Initialising music library');
|
||||||
this.loadIndex();
|
this.loadIndex();
|
||||||
|
await this.#downloader.initialise();
|
||||||
await this.scanLibrary();
|
await this.scanLibrary();
|
||||||
this.#ready = true;
|
this.#ready = true;
|
||||||
this.#logger.info(`Music library initialised with ${this.#index.size} entries`);
|
this.#logger.info(`Music library initialised with ${this.#index.size} entries`);
|
||||||
@ -50,6 +58,35 @@ class MusicLibrary implements Initialisable
|
|||||||
this.saveIndex();
|
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)
|
search (query: MusicQuery)
|
||||||
{
|
{
|
||||||
if (!Object.keys(query).length)
|
if (!Object.keys(query).length)
|
||||||
@ -57,13 +94,15 @@ class MusicLibrary implements Initialisable
|
|||||||
const results: MusicIndexEntry[] = [];
|
const results: MusicIndexEntry[] = [];
|
||||||
for (const entry of this.#index.values())
|
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;
|
continue;
|
||||||
if (query.title && !entry.title.toLowerCase().includes(query.title.toLowerCase()))
|
if (query.title && !entry.title.toLowerCase().includes(query.title.toLowerCase()))
|
||||||
continue;
|
continue;
|
||||||
if (query.keyword
|
if (query.keyword
|
||||||
&& !entry.title.toLowerCase().includes(query.keyword?.toLowerCase())
|
&& !entry.title.toLowerCase().includes(query.keyword?.toLowerCase())
|
||||||
&& !entry.arist.toLowerCase().includes(query.keyword.toLowerCase()))
|
&& !entry.artist.toLowerCase().includes(query.keyword.toLowerCase()))
|
||||||
continue;
|
continue;
|
||||||
results.push(entry);
|
results.push(entry);
|
||||||
}
|
}
|
||||||
@ -71,7 +110,7 @@ class MusicLibrary implements Initialisable
|
|||||||
{
|
{
|
||||||
if (query.artist)
|
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;
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
@ -94,7 +133,10 @@ class MusicLibrary implements Initialisable
|
|||||||
getShufflePlaylist ()
|
getShufflePlaylist ()
|
||||||
{
|
{
|
||||||
const entries = [ ...this.#index.values() ];
|
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)
|
countPlay (fp: string)
|
||||||
@ -124,11 +166,22 @@ class MusicLibrary implements Initialisable
|
|||||||
// Progress reporting
|
// Progress reporting
|
||||||
idx++;
|
idx++;
|
||||||
if (idx % 50 === 0 || idx === filePaths.length)
|
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)}%`);
|
||||||
|
|
||||||
|
await this.#indexSong(fp, full);
|
||||||
|
}
|
||||||
|
const end = Date.now();
|
||||||
|
const newFiles = this.#index.size - initialSize;
|
||||||
|
this.#logger.info(`Library scan took ${end - start} ms, ${this.#index.size} (${newFiles} new) files indexed`);
|
||||||
|
this.saveIndex();
|
||||||
|
return newFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #indexSong (fp: string, force: boolean)
|
||||||
|
{
|
||||||
// Skip already scanned files
|
// Skip already scanned files
|
||||||
if (this.#index.has(fp) && !full)
|
if (this.#index.has(fp) && !force)
|
||||||
continue;
|
return;
|
||||||
|
|
||||||
// Expensive call
|
// Expensive call
|
||||||
const metadata = await parseFile(fp, { skipCovers: true });
|
const metadata = await parseFile(fp, { skipCovers: true });
|
||||||
@ -139,10 +192,12 @@ class MusicLibrary implements Initialisable
|
|||||||
const title = segmets[segmets.length - 1].replace((/\..+$/u), '');
|
const title = segmets[segmets.length - 1].replace((/\..+$/u), '');
|
||||||
|
|
||||||
const entry = {
|
const entry = {
|
||||||
arist: common.artist ?? artist,
|
id: this.#currentId++,
|
||||||
|
artist: common.artist ?? artist,
|
||||||
title: common.title ?? title,
|
title: common.title ?? title,
|
||||||
album: common.album,
|
album: common.album,
|
||||||
year: common.year,
|
year: common.year,
|
||||||
|
genre: common.genre ?? [],
|
||||||
file: fp,
|
file: fp,
|
||||||
stats: {
|
stats: {
|
||||||
plays: 0,
|
plays: 0,
|
||||||
@ -150,12 +205,7 @@ class MusicLibrary implements Initialisable
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
this.#index.set(fp, entry);
|
this.#index.set(fp, entry);
|
||||||
}
|
return entry;
|
||||||
const end = Date.now();
|
|
||||||
const newFiles = this.#index.size - initialSize;
|
|
||||||
this.#logger.info(`Library scan took ${end - start} ms, ${this.#index.size} (${newFiles} new) files indexed`);
|
|
||||||
this.saveIndex();
|
|
||||||
return newFiles;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loadIndex ()
|
loadIndex ()
|
||||||
@ -168,8 +218,8 @@ class MusicLibrary implements Initialisable
|
|||||||
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')
|
if (entry.id >= this.#currentId)
|
||||||
entry.stats = { plays: 0, skips: 0 };
|
this.#currentId = entry.id + 1;
|
||||||
this.#index.set(entry.file, entry);
|
this.#index.set(entry.file, entry);
|
||||||
}
|
}
|
||||||
this.#logger.info(`Index loaded with ${this.#index.size} entries`);
|
this.#logger.info(`Index loaded with ${this.#index.size} entries`);
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
import { ActivityType, ChannelType, Collection, Guild, GuildTextBasedChannel, VoiceChannel } 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 { AudioPlayer, AudioPlayerStatus, AudioResource, PlayerSubscription, VoiceConnection, createAudioPlayer, createAudioResource, joinVoiceChannel } from '@discordjs/voice';
|
||||||
import { LoggerClient } from '@navy.gif/logger';
|
import { LoggerClient } from '@navy.gif/logger';
|
||||||
@ -13,6 +15,7 @@ type ConnectionDetails = {
|
|||||||
textOutput: GuildTextBasedChannel | null
|
textOutput: GuildTextBasedChannel | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cachePath = './cache/player.json';
|
||||||
class MusicPlayer implements Initialisable
|
class MusicPlayer implements Initialisable
|
||||||
{
|
{
|
||||||
#client: DiscordClient;
|
#client: DiscordClient;
|
||||||
@ -65,8 +68,25 @@ class MusicPlayer implements Initialisable
|
|||||||
return this.#library;
|
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);
|
const [ result ] = this.library.search(order);
|
||||||
if (!result)
|
if (!result)
|
||||||
return null;
|
return null;
|
||||||
@ -74,6 +94,12 @@ class MusicPlayer implements Initialisable
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reshuffle ()
|
||||||
|
{
|
||||||
|
this.#shuffleList = this.#library.getShufflePlaylist();
|
||||||
|
this.#shuffleIdx = 0;
|
||||||
|
}
|
||||||
|
|
||||||
playNext ()
|
playNext ()
|
||||||
{
|
{
|
||||||
if (!this.#ready)
|
if (!this.#ready)
|
||||||
@ -97,43 +123,58 @@ class MusicPlayer implements Initialisable
|
|||||||
});
|
});
|
||||||
this.#currentResource.volume?.setVolume(this.#volume);
|
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.#player.play(this.#currentResource);
|
||||||
this.#library.countPlay(info.file);
|
this.#library.countPlay(info.file);
|
||||||
|
|
||||||
this.#shuffleIdx++;
|
this.#shuffleIdx++;
|
||||||
if (this.#shuffleIdx === this.#shuffleList.length)
|
if (this.#shuffleIdx === this.#shuffleList.length)
|
||||||
|
{
|
||||||
this.#shuffleIdx = 0;
|
this.#shuffleIdx = 0;
|
||||||
|
this.#shuffleList = this.#library.getShufflePlaylist();
|
||||||
|
}
|
||||||
|
|
||||||
this.#client.user?.setPresence({
|
this.#client.user?.setPresence({
|
||||||
activities: [{
|
activities: [{
|
||||||
name: `${info.title} by ${info.arist}`,
|
name: `${info.title} by ${info.artist}`,
|
||||||
type: ActivityType.Listening
|
type: ActivityType.Listening
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
|
||||||
const outputChannels = this.#connections.map(obj => obj.textOutput);
|
const outputChannels = this.#connections.map(obj => obj.textOutput);
|
||||||
outputChannels.forEach(async channel =>
|
outputChannels.forEach(channel => this.#sendNowPlaying(channel, info));
|
||||||
{
|
}
|
||||||
if (!channel)
|
|
||||||
return;
|
|
||||||
const messages = await channel.messages.fetch({ limit: 100 });
|
|
||||||
const filtered = messages.filter(msg => msg.author.id === this.#client.user?.id);
|
|
||||||
|
|
||||||
await channel.send({
|
async #sendNowPlaying (channel: GuildTextBasedChannel | null, info: MusicIndexEntry | null)
|
||||||
|
{
|
||||||
|
if (!channel || !info)
|
||||||
|
return;
|
||||||
|
const payload = {
|
||||||
embeds: [{
|
embeds: [{
|
||||||
title: 'Now playing :notes:',
|
title: 'Now playing :notes:',
|
||||||
description: `**${info!.title}** by ${info!.arist}`,
|
description: `**${info.title}** by ${info.artist}`,
|
||||||
color: 0xffafff
|
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)
|
||||||
|
{
|
||||||
|
latest.edit(payload);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
latest = null;
|
||||||
|
await channel.send(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = messages.filter(msg => msg.author.id === this.#client.user?.id && msg.id !== latest?.id);
|
||||||
for (const [ , msg ] of filtered)
|
for (const [ , msg ] of filtered)
|
||||||
{
|
{
|
||||||
if (msg.deletable && (msg.embeds.length || msg.content.startsWith('Now playing')))
|
if (msg.deletable && (msg.embeds.length || msg.content.startsWith('Now playing')))
|
||||||
await msg.delete();
|
await msg.delete();
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get volume ()
|
get volume ()
|
||||||
@ -160,6 +201,12 @@ class MusicPlayer implements Initialisable
|
|||||||
connection.disconnect();
|
connection.disconnect();
|
||||||
}
|
}
|
||||||
this.#library.stop();
|
this.#library.stop();
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
volume: this.#volume,
|
||||||
|
queue: this.#queue
|
||||||
|
};
|
||||||
|
fs.writeFileSync(cachePath, JSON.stringify(config));
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialise ()
|
async initialise ()
|
||||||
@ -168,6 +215,13 @@ class MusicPlayer implements Initialisable
|
|||||||
return;
|
return;
|
||||||
this.#logger.info('Initialising music player');
|
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();
|
await this.#library.initialise();
|
||||||
|
|
||||||
this.#shuffleList = this.#library.getShufflePlaylist();
|
this.#shuffleList = this.#library.getShufflePlaylist();
|
||||||
|
@ -9,6 +9,7 @@ import { isInitialisable } from '../../interfaces/Initialisable.js';
|
|||||||
import Command from '../../interfaces/Command.js';
|
import Command from '../../interfaces/Command.js';
|
||||||
import Inhibitor from '../../interfaces/Inhibitor.js';
|
import Inhibitor from '../../interfaces/Inhibitor.js';
|
||||||
import Observer from '../../interfaces/Observer.js';
|
import Observer from '../../interfaces/Observer.js';
|
||||||
|
import Downloader from '../../interfaces/Downloader.js';
|
||||||
|
|
||||||
class Registry
|
class Registry
|
||||||
{
|
{
|
||||||
@ -124,6 +125,11 @@ class Registry
|
|||||||
return this.#components.filter(comp => comp.type === 'inhibitor') as Collection<string, Inhibitor>;
|
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 ()
|
get components ()
|
||||||
{
|
{
|
||||||
return this.#components.clone();
|
return this.#components.clone();
|
||||||
|
46
src/client/components/commands/Request.ts
Normal file
46
src/client/components/commands/Request.ts
Normal 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;
|
@ -1,5 +1,7 @@
|
|||||||
|
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';
|
||||||
|
import { CommandOpts, OptionType } from '@navy.gif/commandparser';
|
||||||
|
|
||||||
class PingCommand extends Command
|
class PingCommand extends Command
|
||||||
{
|
{
|
||||||
@ -7,13 +9,20 @@ class PingCommand extends Command
|
|||||||
{
|
{
|
||||||
super(client, {
|
super(client, {
|
||||||
name: 'rescan',
|
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;
|
const songs = this.client.musicPlayer.library.size;
|
||||||
return `Found ${songs} tracks with ${diff} new ones`;
|
return `Found ${songs} tracks with ${diff} new ones`;
|
||||||
}
|
}
|
||||||
|
22
src/client/components/commands/Reshuffle.ts
Normal file
22
src/client/components/commands/Reshuffle.ts
Normal 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;
|
@ -34,7 +34,7 @@ class SearchCommand extends Command
|
|||||||
if (!results.length)
|
if (!results.length)
|
||||||
return 'No results found';
|
return 'No results found';
|
||||||
return `
|
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')}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
112
src/client/components/downloaders/Spotify.ts
Normal file
112
src/client/components/downloaders/Spotify.ts
Normal 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;
|
4
src/errors/MusicPlayerError.ts
Normal file
4
src/errors/MusicPlayerError.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
class MusicPlayerError extends Error
|
||||||
|
{}
|
||||||
|
|
||||||
|
export default MusicPlayerError;
|
48
src/interfaces/Downloader.ts
Normal file
48
src/interfaces/Downloader.ts
Normal 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;
|
@ -17,6 +17,7 @@ class Controller
|
|||||||
#logger: MasterLogger;
|
#logger: MasterLogger;
|
||||||
#version: string;
|
#version: string;
|
||||||
#shardingOptions: ShardingOptions;
|
#shardingOptions: ShardingOptions;
|
||||||
|
#exiting: boolean;
|
||||||
// #options: ControllerOptions;
|
// #options: ControllerOptions;
|
||||||
// #readyAt!: number;
|
// #readyAt!: number;
|
||||||
|
|
||||||
@ -49,6 +50,10 @@ class Controller
|
|||||||
|
|
||||||
this.#version = version;
|
this.#version = version;
|
||||||
this.#ready = false;
|
this.#ready = false;
|
||||||
|
this.#exiting = false;
|
||||||
|
|
||||||
|
process.on('SIGINT', this.shutdown.bind(this));
|
||||||
|
process.on('SIGTERM', this.shutdown.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
async build ()
|
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.#logger.status(`Shards spawned, spawned ${this.#shards.size} shards. Took ${Date.now() - start} ms`);
|
||||||
|
|
||||||
this.#ready = true;
|
this.#ready = true;
|
||||||
@ -233,7 +236,10 @@ class Controller
|
|||||||
|
|
||||||
async shutdown ()
|
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);
|
setTimeout(process.exit, 90_000);
|
||||||
const promises = this.#shards
|
const promises = this.#shards
|
||||||
.filter(shard => shard.ready)
|
.filter(shard => shard.ready)
|
||||||
|
13
yarn.lock
13
yarn.lock
@ -1628,10 +1628,10 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@navy.gif/commandparser@npm:^1.6.5":
|
"@navy.gif/commandparser@npm:^1.6.6":
|
||||||
version: 1.6.5
|
version: 1.6.6
|
||||||
resolution: "@navy.gif/commandparser@npm:1.6.5"
|
resolution: "@navy.gif/commandparser@npm:1.6.6"
|
||||||
checksum: 10/f0bc838ab7785dea28a91ca07e96a52ca58596032fe0d92badda246e90e245987f2d00e5ea5776fa156033c7f6ffd6fe6e1f9fb7d58096c60d3f33f08510532c
|
checksum: 10/1020ef32bd3b2b2e75dbbbf4814829765ec354ccac1c980978b49ebf2a6bc94cc7031691d2aa275ad98781027aa5cabe7bede8aecbd2d5a3bf8a48440b8befdf
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@ -1842,7 +1842,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@types/node@npm:*":
|
"@types/node@npm:*, @types/node@npm:^20.11.30":
|
||||||
version: 20.11.30
|
version: 20.11.30
|
||||||
resolution: "@types/node@npm:20.11.30"
|
resolution: "@types/node@npm:20.11.30"
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -3577,7 +3577,7 @@ __metadata:
|
|||||||
"@babel/preset-typescript": "npm:^7.24.1"
|
"@babel/preset-typescript": "npm:^7.24.1"
|
||||||
"@discordjs/opus": "npm:^0.9.0"
|
"@discordjs/opus": "npm:^0.9.0"
|
||||||
"@discordjs/voice": "npm:^0.16.1"
|
"@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/logger": "npm:^2.5.4"
|
||||||
"@navy.gif/timestring": "npm:^6.0.6"
|
"@navy.gif/timestring": "npm:^6.0.6"
|
||||||
"@types/babel__core": "npm:^7"
|
"@types/babel__core": "npm:^7"
|
||||||
@ -3585,6 +3585,7 @@ __metadata:
|
|||||||
"@types/common-tags": "npm:^1"
|
"@types/common-tags": "npm:^1"
|
||||||
"@types/eslint": "npm:^8"
|
"@types/eslint": "npm:^8"
|
||||||
"@types/humanize-duration": "npm:^3"
|
"@types/humanize-duration": "npm:^3"
|
||||||
|
"@types/node": "npm:^20.11.30"
|
||||||
"@types/similarity": "npm:^1"
|
"@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"
|
||||||
|
Loading…
Reference in New Issue
Block a user