Add info command and yt-dlp downloader for youtube
update some packages
This commit is contained in:
parent
9a7c53c67e
commit
16ee666c4f
3
@types/Downloader.d.ts
vendored
3
@types/Downloader.d.ts
vendored
@ -11,7 +11,8 @@ type ExistingResult = {
|
||||
} & BaseResult
|
||||
|
||||
type DownloadedResult = {
|
||||
filePath: string
|
||||
filePath: string,
|
||||
temp?: boolean
|
||||
} & BaseResult
|
||||
|
||||
export type DownloaderResult = ExistingResult | DownloadedResult;
|
3
@types/MusicPlayer.d.ts
vendored
3
@types/MusicPlayer.d.ts
vendored
@ -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 = {
|
||||
|
@ -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",
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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<MusicIndexEntry | null>
|
||||
{
|
||||
// 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;
|
||||
|
@ -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)
|
||||
|
43
src/client/components/commands/Info.ts
Normal file
43
src/client/components/commands/Info.ts
Normal file
@ -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;
|
@ -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');
|
||||
|
142
src/client/components/downloaders/yt-dlp.ts
Normal file
142
src/client/components/downloaders/yt-dlp.ts
Normal file
@ -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<void>
|
||||
{
|
||||
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<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 promise = new Promise<DownloaderResult>((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;
|
4
src/errors/DownloaderError.ts
Normal file
4
src/errors/DownloaderError.ts
Normal file
@ -0,0 +1,4 @@
|
||||
class DownloaderError extends Error
|
||||
{}
|
||||
|
||||
export default DownloaderError;
|
13
yarn.lock
13
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"
|
||||
|
Loading…
Reference in New Issue
Block a user