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",
|
||||
"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
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 = {
|
||||
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
|
||||
}
|
@ -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
|
||||
|
@ -1,3 +1,3 @@
|
||||
# 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",
|
||||
"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",
|
||||
|
@ -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();
|
||||
|
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 { 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`);
|
||||
|
@ -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();
|
||||
|
@ -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();
|
||||
|
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 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`;
|
||||
}
|
||||
|
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)
|
||||
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')}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
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;
|
||||
#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)
|
||||
|
13
yarn.lock
13
yarn.lock
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user