From 822bf608f38e38726dc6b8ddbf0f07ac0d1d8d3b Mon Sep 17 00:00:00 2001 From: "Navy.gif" Date: Wed, 27 Mar 2024 18:14:37 +0200 Subject: [PATCH] Bunch of updates and fixes rebuild option in rescan request command to request songs music downloader classes misc fixes --- .eslintrc.json | 3 +- @types/Downloader.d.ts | 7 ++ @types/MusicPlayer.d.ts | 10 +- Dockerfile | 3 + README.md | 2 +- package.json | 5 +- src/client/DiscordClient.ts | 3 + src/client/components/MusicDownloader.ts | 80 +++++++++++++ src/client/components/MusicLibrary.ts | 112 ++++++++++++++----- src/client/components/MusicPlayer.ts | 96 ++++++++++++---- src/client/components/Registry.ts | 6 + src/client/components/commands/Request.ts | 46 ++++++++ src/client/components/commands/Rescan.ts | 15 ++- src/client/components/commands/Reshuffle.ts | 22 ++++ src/client/components/commands/Search.ts | 2 +- src/client/components/downloaders/Spotify.ts | 112 +++++++++++++++++++ src/errors/MusicPlayerError.ts | 4 + src/interfaces/Downloader.ts | 48 ++++++++ src/middleware/Controller.ts | 12 +- yarn.lock | 13 ++- 20 files changed, 529 insertions(+), 72 deletions(-) create mode 100644 @types/Downloader.d.ts create mode 100644 src/client/components/MusicDownloader.ts create mode 100644 src/client/components/commands/Request.ts create mode 100644 src/client/components/commands/Reshuffle.ts create mode 100644 src/client/components/downloaders/Spotify.ts create mode 100644 src/errors/MusicPlayerError.ts create mode 100644 src/interfaces/Downloader.ts diff --git a/.eslintrc.json b/.eslintrc.json index 2104120..c2218ab 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -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", diff --git a/@types/Downloader.d.ts b/@types/Downloader.d.ts new file mode 100644 index 0000000..d25cbee --- /dev/null +++ b/@types/Downloader.d.ts @@ -0,0 +1,7 @@ +export type DownloaderResult = { + filePath: string, + artist: string, + song: string, + album: string, + ext: string +}; \ No newline at end of file diff --git a/@types/MusicPlayer.d.ts b/@types/MusicPlayer.d.ts index e458160..d308c45 100644 --- a/@types/MusicPlayer.d.ts +++ b/@types/MusicPlayer.d.ts @@ -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 } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 96dd930..47ad225 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index e20766f..847402c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ # music-bot -A bot that plays music on Discord \ No newline at end of file +A bot that plays music on Discord using your local library \ No newline at end of file diff --git a/package.json b/package.json index 6cc6857..c8189a5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/client/DiscordClient.ts b/src/client/DiscordClient.ts index 8cf3567..2073dce 100644 --- a/src/client/DiscordClient.ts +++ b/src/client/DiscordClient.ts @@ -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(); diff --git a/src/client/components/MusicDownloader.ts b/src/client/components/MusicDownloader.ts new file mode 100644 index 0000000..6aeca14 --- /dev/null +++ b/src/client/components/MusicDownloader.ts @@ -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; + #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 + { + 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 + { + 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; \ No newline at end of file diff --git a/src/client/components/MusicLibrary.ts b/src/client/components/MusicLibrary.ts index 49a4c49..22429cc 100644 --- a/src/client/components/MusicLibrary.ts +++ b/src/client/components/MusicLibrary.ts @@ -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\.)?)?(?([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; #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`); diff --git a/src/client/components/MusicPlayer.ts b/src/client/components/MusicPlayer.ts index 63d227a..731557a 100644 --- a/src/client/components/MusicPlayer.ts +++ b/src/client/components/MusicPlayer.ts @@ -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(); diff --git a/src/client/components/Registry.ts b/src/client/components/Registry.ts index 5c4dabe..ef53ee8 100644 --- a/src/client/components/Registry.ts +++ b/src/client/components/Registry.ts @@ -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; } + get downloaders () + { + return this.#components.filter(comp => comp.type === 'downloader') as Collection; + } + get components () { return this.#components.clone(); diff --git a/src/client/components/commands/Request.ts b/src/client/components/commands/Request.ts new file mode 100644 index 0000000..2de46e4 --- /dev/null +++ b/src/client/components/commands/Request.ts @@ -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; \ No newline at end of file diff --git a/src/client/components/commands/Rescan.ts b/src/client/components/commands/Rescan.ts index c327d63..bdb81c3 100644 --- a/src/client/components/commands/Rescan.ts +++ b/src/client/components/commands/Rescan.ts @@ -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`; } diff --git a/src/client/components/commands/Reshuffle.ts b/src/client/components/commands/Reshuffle.ts new file mode 100644 index 0000000..0d54c8b --- /dev/null +++ b/src/client/components/commands/Reshuffle.ts @@ -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; \ No newline at end of file diff --git a/src/client/components/commands/Search.ts b/src/client/components/commands/Search.ts index 91c1f0f..5cee46c 100644 --- a/src/client/components/commands/Search.ts +++ b/src/client/components/commands/Search.ts @@ -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')} `; } } diff --git a/src/client/components/downloaders/Spotify.ts b/src/client/components/downloaders/Spotify.ts new file mode 100644 index 0000000..2fb4531 --- /dev/null +++ b/src/client/components/downloaders/Spotify.ts @@ -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 + { + 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 + { + 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; \ No newline at end of file diff --git a/src/errors/MusicPlayerError.ts b/src/errors/MusicPlayerError.ts new file mode 100644 index 0000000..2dfde9f --- /dev/null +++ b/src/errors/MusicPlayerError.ts @@ -0,0 +1,4 @@ +class MusicPlayerError extends Error +{} + +export default MusicPlayerError; \ No newline at end of file diff --git a/src/interfaces/Downloader.ts b/src/interfaces/Downloader.ts new file mode 100644 index 0000000..bf5c5f8 --- /dev/null +++ b/src/interfaces/Downloader.ts @@ -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)} + */ + abstract test(): Promise | void; + + abstract download(url: string): Promise; + + get downloadDir () + { + return this.#downloadDir; + } + + set downloadDir (val: string | undefined) + { + this.#downloadDir = val; + } +} + +export default Downloader; \ No newline at end of file diff --git a/src/middleware/Controller.ts b/src/middleware/Controller.ts index f5851bb..3e435d6 100644 --- a/src/middleware/Controller.ts +++ b/src/middleware/Controller.ts @@ -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) diff --git a/yarn.lock b/yarn.lock index ab95239..24b9530 100644 --- a/yarn.lock +++ b/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"