diff --git a/@types/Downloader.d.ts b/@types/Downloader.d.ts index 45057a7..d2468ad 100644 --- a/@types/Downloader.d.ts +++ b/@types/Downloader.d.ts @@ -11,7 +11,8 @@ type ExistingResult = { } & BaseResult type DownloadedResult = { - filePath: string + filePath: string, + temp?: boolean } & BaseResult export type DownloaderResult = ExistingResult | DownloadedResult; \ No newline at end of file diff --git a/@types/MusicPlayer.d.ts b/@types/MusicPlayer.d.ts index e33d3fa..66fab16 100644 --- a/@types/MusicPlayer.d.ts +++ b/@types/MusicPlayer.d.ts @@ -16,7 +16,8 @@ export type MusicIndexEntry = { album?: string, year?: number, genre: string[] - file: string + file: string, + temp?: boolean // File is deleted after playing } export type MusicStatsEntry = { diff --git a/package.json b/package.json index 82f4d68..cdd664c 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "@navy.gif/commandparser": "^1.8.2", "@navy.gif/logger": "^2.5.4", "@navy.gif/timestring": "^6.0.6", - "@types/node": "^20.11.30", + "@types/node": "^20.14.11", "bufferutil": "^4.0.8", "common-tags": "^1.8.2", "discord.js": "^14.14.1", diff --git a/src/client/components/MusicDownloader.ts b/src/client/components/MusicDownloader.ts index da67ceb..237cd2f 100644 --- a/src/client/components/MusicDownloader.ts +++ b/src/client/components/MusicDownloader.ts @@ -6,6 +6,7 @@ import { LoggerClient } from '@navy.gif/logger'; import Initialisable from '../../interfaces/Initialisable.js'; import Downloader from '../../interfaces/Downloader.js'; import DiscordClient from '../DiscordClient.js'; +import DownloaderError from '../../errors/DownloaderError.js'; class MusicDownloader implements Initialisable { @@ -52,7 +53,7 @@ class MusicDownloader implements Initialisable if (!downloader) throw new Error('No such downloader'); if (!downloader.available) - throw new Error('Downloader not available'); + throw new DownloaderError('Downloader not available'); return downloader.download(url); } diff --git a/src/client/components/MusicLibrary.ts b/src/client/components/MusicLibrary.ts index 5680c4e..90a7c88 100644 --- a/src/client/components/MusicLibrary.ts +++ b/src/client/components/MusicLibrary.ts @@ -20,6 +20,13 @@ const defaultStats: MusicStatsEntry = { plays: 0, skips: 0 }; + +const domains = [ + 'youtube.com', + 'youtu.be', + 'soundcloud.com' +]; + class MusicLibrary implements Initialisable { #path: string; @@ -85,9 +92,11 @@ class MusicLibrary implements Initialisable { 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'); @@ -96,6 +105,8 @@ class MusicLibrary implements Initialisable let result: DownloaderResult | null = null; if (domain.includes('spotify.com')) result = await this.#downloader.download('spotify', keyword); + else if (domains.some(dom => domain.includes(dom))) + result = await this.#downloader.download('yt-dlp', keyword); else throw new MusicPlayerError('Unsupported domain'); @@ -109,12 +120,28 @@ class MusicLibrary implements Initialisable return entry; } - const targetDir = path.join(this.#path, result.artist, result.album); - if (!fs.existsSync(targetDir)) - fs.mkdirSync(targetDir, { recursive: true }); - const newFile = path.join(targetDir, `${result.song}.${result.ext}`); - fs.renameSync(result.filePath, newFile); - const entry = await this.#indexSong(newFile, false); + let entry: MusicIndexEntry | null = null; + if (result.temp) + { + entry = { + id: -1, + artist: result.artist, + title: result.song, + album: result.album, + genre: [], + file: result.filePath, + temp: true + }; + } + else + { + const targetDir = path.join(this.#path, result.artist, result.album); + if (!fs.existsSync(targetDir)) + fs.mkdirSync(targetDir, { recursive: true }); + const newFile = path.join(targetDir, `${result.song}.${result.ext}`); + fs.renameSync(result.filePath, newFile); + entry = await this.#indexSong(newFile, false); + } return entry; } @@ -212,7 +239,7 @@ class MusicLibrary implements Initialisable { this.#logger.info('Starting library scan'); const start = Date.now(); - const filePaths = await Util.readdirRecursive(this.#path, { ignoreSuffixes: [ '.nfo', '.jpg', '.jpeg', '.png' ] }); + const filePaths = await Util.readdirRecursive(this.#path, { ignoreSuffixes: [ '.nfo', '.jpg', '.jpeg', '.png', '.lrc' ] }); this.#logger.debug(`${filePaths.length} files to go through`); if (!this.#index.size) this.#logger.info('No index built, performing first time scan. This may take some time depending on the size of your music library'); @@ -241,11 +268,11 @@ class MusicLibrary implements Initialisable return newFiles; } - async #indexSong (fp: string, force: boolean) + async #indexSong (fp: string, force: boolean): Promise { // Skip already scanned files if (this.#index.has(fp) && !force) - return; + return null; // Expensive call let metadata = null; @@ -258,7 +285,7 @@ class MusicLibrary implements Initialisable const error = err as Error; if (!error.message.includes('MIME-type')) this.#logger.error(`Error while parsing file: ${fp}\n${error.stack}`); - return; + return null; } const { common } = metadata; // Fall back to file and folder name if artist or title not available @@ -266,18 +293,14 @@ class MusicLibrary implements Initialisable const artist = segmets[segmets.length - 2]; const title = segmets[segmets.length - 1].replace((/\..+$/u), ''); - const entry = { + const entry: MusicIndexEntry = { 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 - } + file: fp }; this.#index.set(fp, entry); return entry; diff --git a/src/client/components/MusicPlayer.ts b/src/client/components/MusicPlayer.ts index cc6d08d..3335193 100644 --- a/src/client/components/MusicPlayer.ts +++ b/src/client/components/MusicPlayer.ts @@ -140,6 +140,13 @@ class MusicPlayer implements Initialisable if (this.#player.state.status !== AudioPlayerStatus.Idle && this.#currentSong) this.library.countSkip(this.#currentSong.title); + // Temporary song files are deleted after being played + if (this.#currentSong?.temp) + { + this.#logger.debug(`Deleting temp song file: ${this.#currentSong.file}`); + fs.unlinkSync(this.#currentSong.file); + } + let info: MusicIndexEntry | null = null; if (this.#queue.length) info = this.#queue.shift()!; @@ -240,7 +247,6 @@ class MusicPlayer implements Initialisable guildId: guild.id, adapterCreator: guild.voiceAdapterCreator }); - connection.removeAllListeners(); const subscription = connection.subscribe(this.#player); if (!subscription) diff --git a/src/client/components/commands/Info.ts b/src/client/components/commands/Info.ts new file mode 100644 index 0000000..d076f29 --- /dev/null +++ b/src/client/components/commands/Info.ts @@ -0,0 +1,43 @@ +import { CommandArgs } from '@navy.gif/commandparser'; +import Command from '../../../interfaces/Command.js'; +import DiscordClient from '../../DiscordClient.js'; +import { stripIndents } from 'common-tags'; +import path from 'node:path'; + +class InfoCommand extends Command +{ + constructor (client: DiscordClient) + { + super(client, { + name: 'info', + description: 'Display information about a song', + options: [ + { + name: 'song', + } + ] + }); + } + async execute (_message: unknown, args: CommandArgs) + { + const [ songArg ] = args.map.get('song') ?? []; + const songName = songArg?.value as string | null; + let song = this.client.musicPlayer.current; + if (songName) + [ song ] = this.client.musicPlayer.library.search({ keyword: songName }); + + if (!song) + return `No song found matching ${songName}`; + return stripIndents` + **Name:** ${song.title} + **Artist:** ${song.artist} + **Genre:** ${song.genre.join(', ') || 'No genre info'} + **Year:** ${song.year ?? 'N/A'} + **Format:** ${path.extname(song.file).replace('.', '')} + **ID:** ${song.id} + `; + } + +} + +export default InfoCommand; \ No newline at end of file diff --git a/src/client/components/commands/Request.ts b/src/client/components/commands/Request.ts index c9760a0..770af1b 100644 --- a/src/client/components/commands/Request.ts +++ b/src/client/components/commands/Request.ts @@ -4,6 +4,7 @@ import { CommandArgs } from '@navy.gif/commandparser'; import Command from '../../../interfaces/Command.js'; import DiscordClient from '../../DiscordClient.js'; import MusicPlayerError from '../../../errors/MusicPlayerError.js'; +import DownloaderError from '../../../errors/DownloaderError.js'; class RequestCommand extends Command { @@ -39,7 +40,7 @@ class RequestCommand extends Command } catch (err) { - if (err instanceof MusicPlayerError) + if (err instanceof MusicPlayerError || err instanceof DownloaderError) return response.edit(`**Error while requesting song:**\n${err.message}`); this.logger.error(err as Error); return response.edit('Internal error, this has been logged'); diff --git a/src/client/components/downloaders/yt-dlp.ts b/src/client/components/downloaders/yt-dlp.ts new file mode 100644 index 0000000..2ae5d89 --- /dev/null +++ b/src/client/components/downloaders/yt-dlp.ts @@ -0,0 +1,142 @@ +import { exec } from '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 'path'; + +type QueueEntry = { + resolve: (result: DownloaderResult) => void, + reject: (error: Error) => void, + url: string +} + +class YtdlpDownloader extends Downloader +{ + #executable: string; + #available: boolean; + #options: string[]; + #processing: boolean; + #queue: QueueEntry[]; + + constructor (client: DiscordClient) + { + super(client, { + name: 'yt-dlp' + }); + this.#executable = 'yt-dlp'; + this.#available = false; + this.#options = [ + '--no-playlist', // Avoid downloading playlists + // '--limit-rate', '5M', // Download at 5MB/s + '--max-filesize', '10M', // Limit to 10 MB, should prevent downloading livestreams or overly large files + '--paths', 'cache/youtube', // Output dir + '--restrict-filenames', // Filename can only contain ASCII characters + '-x', // Extract audio + '--audio-format', 'mp3', // Extract audio in mp3 + '--audio-quality', '0', // Extract as best audio quality + // '--remux-video', 'mp4', // Repackage the video in MP4 format + // '--recode-video', 'mp4', // Recode if necessary + // '-o {name}.%(ext)s', // Template for output file name + ]; + + this.#queue = []; + this.#processing = false; + + } + + override get available (): boolean + { + return this.#available; + } + + override test (): void | Promise + { + return new Promise((resolve, reject) => + { + 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 promise = new Promise((resolve, reject) => + { + this.#queue.push({ + resolve, reject, url + }); + this.#processQueue(); + }); + + promise.finally(() => + { + this.#processing = false; + this.#processQueue(); + }); + + return promise; + } + + async #processQueue () + { + if (this.#processing) + return; + + const entry = this.#queue.shift(); + if (!entry) + return; + + this.#processing = true; + + const { resolve, reject, url } = entry; + this.logger.info(`Processing queue, downloading ${url}`); + const ts = Date.now(); + exec(`${this.#executable} ${this.#options.join(' ')} -o ${ts}-%(title)s.%(ext)s ${url}`, (error, stdout, stderr) => + { + if (error) + return reject(error); + if (stderr && stderr.includes('ERROR: ')) + { + this.logger.error(stderr); + return reject(new Error('Unknown error')); + } + this.logger.debug(stdout); + const files = fs.readdirSync('./cache/youtube'); + const file = files.find(f => f.startsWith(ts.toString())); + if (!file) + return reject(new Error('Download failed, could not locate file')); + + const ext = path.extname(file); + const name = file + .replace(`${ts}-`, '') + .replace('_', ' ') + .replace(ext, ''); + + resolve({ + filePath: path.resolve('./cache/youtube', file), + artist: 'Unknown', + album: 'Unknown', + song: name, + temp: true, + ext, + }); + }); + } + +} + +export default YtdlpDownloader; \ No newline at end of file diff --git a/src/errors/DownloaderError.ts b/src/errors/DownloaderError.ts new file mode 100644 index 0000000..338e417 --- /dev/null +++ b/src/errors/DownloaderError.ts @@ -0,0 +1,4 @@ +class DownloaderError extends Error +{} + +export default DownloaderError; \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 9845d0e..eb763e1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1844,7 +1844,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:^20.11.30": +"@types/node@npm:*": version: 20.11.30 resolution: "@types/node@npm:20.11.30" dependencies: @@ -1853,6 +1853,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^20.14.11": + version: 20.14.11 + resolution: "@types/node@npm:20.14.11" + dependencies: + undici-types: "npm:~5.26.4" + checksum: 10/344e1ce1ed16c86ed1c4209ab4d1de67db83dd6b694a6fabe295c47144dde2c58dabddae9f39a0a2bdd246e95f8d141ccfe848e464884b48b8918df4f7788025 + languageName: node + linkType: hard + "@types/semver@npm:^7.5.0": version: 7.5.8 resolution: "@types/semver@npm:7.5.8" @@ -3587,7 +3596,7 @@ __metadata: "@types/common-tags": "npm:^1" "@types/eslint": "npm:^8" "@types/humanize-duration": "npm:^3" - "@types/node": "npm:^20.11.30" + "@types/node": "npm:^20.14.11" "@types/similarity": "npm:^1" "@typescript-eslint/eslint-plugin": "npm:^7.3.1" "@typescript-eslint/parser": "npm:^7.3.1"