Add info command and yt-dlp downloader for youtube

update some packages
This commit is contained in:
Erik 2024-07-24 18:07:13 +03:00
parent 9a7c53c67e
commit 16ee666c4f
11 changed files with 255 additions and 24 deletions

View File

@ -11,7 +11,8 @@ type ExistingResult = {
} & BaseResult } & BaseResult
type DownloadedResult = { type DownloadedResult = {
filePath: string filePath: string,
temp?: boolean
} & BaseResult } & BaseResult
export type DownloaderResult = ExistingResult | DownloadedResult; export type DownloaderResult = ExistingResult | DownloadedResult;

View File

@ -16,7 +16,8 @@ export type MusicIndexEntry = {
album?: string, album?: string,
year?: number, year?: number,
genre: string[] genre: string[]
file: string file: string,
temp?: boolean // File is deleted after playing
} }
export type MusicStatsEntry = { export type MusicStatsEntry = {

View File

@ -23,7 +23,7 @@
"@navy.gif/commandparser": "^1.8.2", "@navy.gif/commandparser": "^1.8.2",
"@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", "@types/node": "^20.14.11",
"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",

View File

@ -6,6 +6,7 @@ import { LoggerClient } from '@navy.gif/logger';
import Initialisable from '../../interfaces/Initialisable.js'; import Initialisable from '../../interfaces/Initialisable.js';
import Downloader from '../../interfaces/Downloader.js'; import Downloader from '../../interfaces/Downloader.js';
import DiscordClient from '../DiscordClient.js'; import DiscordClient from '../DiscordClient.js';
import DownloaderError from '../../errors/DownloaderError.js';
class MusicDownloader implements Initialisable class MusicDownloader implements Initialisable
{ {
@ -52,7 +53,7 @@ class MusicDownloader implements Initialisable
if (!downloader) if (!downloader)
throw new Error('No such downloader'); throw new Error('No such downloader');
if (!downloader.available) if (!downloader.available)
throw new Error('Downloader not available'); throw new DownloaderError('Downloader not available');
return downloader.download(url); return downloader.download(url);
} }

View File

@ -20,6 +20,13 @@ const defaultStats: MusicStatsEntry = {
plays: 0, plays: 0,
skips: 0 skips: 0
}; };
const domains = [
'youtube.com',
'youtu.be',
'soundcloud.com'
];
class MusicLibrary implements Initialisable class MusicLibrary implements Initialisable
{ {
#path: string; #path: string;
@ -85,9 +92,11 @@ class MusicLibrary implements Initialisable
{ {
if (!linkReg.test(keyword)) if (!linkReg.test(keyword))
throw new MusicPlayerError('Invalid link'); throw new MusicPlayerError('Invalid link');
const match = keyword.match(linkReg); const match = keyword.match(linkReg);
if (!match || !match.groups) if (!match || !match.groups)
throw new Error('Missing match??'); throw new Error('Missing match??');
const { domain } = match.groups; const { domain } = match.groups;
if (!domain) if (!domain)
throw new MusicPlayerError('Invalid link'); throw new MusicPlayerError('Invalid link');
@ -96,6 +105,8 @@ class MusicLibrary implements Initialisable
let result: DownloaderResult | null = null; let result: DownloaderResult | null = null;
if (domain.includes('spotify.com')) if (domain.includes('spotify.com'))
result = await this.#downloader.download('spotify', keyword); result = await this.#downloader.download('spotify', keyword);
else if (domains.some(dom => domain.includes(dom)))
result = await this.#downloader.download('yt-dlp', keyword);
else else
throw new MusicPlayerError('Unsupported domain'); throw new MusicPlayerError('Unsupported domain');
@ -109,12 +120,28 @@ class MusicLibrary implements Initialisable
return entry; return entry;
} }
const targetDir = path.join(this.#path, result.artist, result.album); let entry: MusicIndexEntry | null = null;
if (!fs.existsSync(targetDir)) if (result.temp)
fs.mkdirSync(targetDir, { recursive: true }); {
const newFile = path.join(targetDir, `${result.song}.${result.ext}`); entry = {
fs.renameSync(result.filePath, newFile); id: -1,
const entry = await this.#indexSong(newFile, false); 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; return entry;
} }
@ -212,7 +239,7 @@ class MusicLibrary implements Initialisable
{ {
this.#logger.info('Starting library scan'); this.#logger.info('Starting library scan');
const start = Date.now(); 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`); this.#logger.debug(`${filePaths.length} files to go through`);
if (!this.#index.size) 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'); 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; return newFiles;
} }
async #indexSong (fp: string, force: boolean) async #indexSong (fp: string, force: boolean): Promise<MusicIndexEntry | null>
{ {
// Skip already scanned files // Skip already scanned files
if (this.#index.has(fp) && !force) if (this.#index.has(fp) && !force)
return; return null;
// Expensive call // Expensive call
let metadata = null; let metadata = null;
@ -258,7 +285,7 @@ class MusicLibrary implements Initialisable
const error = err as Error; const error = err as Error;
if (!error.message.includes('MIME-type')) if (!error.message.includes('MIME-type'))
this.#logger.error(`Error while parsing file: ${fp}\n${error.stack}`); this.#logger.error(`Error while parsing file: ${fp}\n${error.stack}`);
return; return null;
} }
const { common } = metadata; const { common } = metadata;
// Fall back to file and folder name if artist or title not available // 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 artist = segmets[segmets.length - 2];
const title = segmets[segmets.length - 1].replace((/\..+$/u), ''); const title = segmets[segmets.length - 1].replace((/\..+$/u), '');
const entry = { const entry: MusicIndexEntry = {
id: this.#currentId++, id: this.#currentId++,
artist: common.artist ?? artist, 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 ?? [], genre: common.genre ?? [],
file: fp, file: fp
stats: {
plays: 0,
skips: 0
}
}; };
this.#index.set(fp, entry); this.#index.set(fp, entry);
return entry; return entry;

View File

@ -140,6 +140,13 @@ class MusicPlayer implements Initialisable
if (this.#player.state.status !== AudioPlayerStatus.Idle && this.#currentSong) if (this.#player.state.status !== AudioPlayerStatus.Idle && this.#currentSong)
this.library.countSkip(this.#currentSong.title); 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; let info: MusicIndexEntry | null = null;
if (this.#queue.length) if (this.#queue.length)
info = this.#queue.shift()!; info = this.#queue.shift()!;
@ -240,7 +247,6 @@ class MusicPlayer implements Initialisable
guildId: guild.id, guildId: guild.id,
adapterCreator: guild.voiceAdapterCreator adapterCreator: guild.voiceAdapterCreator
}); });
connection.removeAllListeners();
const subscription = connection.subscribe(this.#player); const subscription = connection.subscribe(this.#player);
if (!subscription) if (!subscription)

View 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;

View File

@ -4,6 +4,7 @@ import { CommandArgs } from '@navy.gif/commandparser';
import Command from '../../../interfaces/Command.js'; import Command from '../../../interfaces/Command.js';
import DiscordClient from '../../DiscordClient.js'; import DiscordClient from '../../DiscordClient.js';
import MusicPlayerError from '../../../errors/MusicPlayerError.js'; import MusicPlayerError from '../../../errors/MusicPlayerError.js';
import DownloaderError from '../../../errors/DownloaderError.js';
class RequestCommand extends Command class RequestCommand extends Command
{ {
@ -39,7 +40,7 @@ class RequestCommand extends Command
} }
catch (err) catch (err)
{ {
if (err instanceof MusicPlayerError) if (err instanceof MusicPlayerError || err instanceof DownloaderError)
return response.edit(`**Error while requesting song:**\n${err.message}`); return response.edit(`**Error while requesting song:**\n${err.message}`);
this.logger.error(err as Error); this.logger.error(err as Error);
return response.edit('Internal error, this has been logged'); return response.edit('Internal error, this has been logged');

View 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;

View File

@ -0,0 +1,4 @@
class DownloaderError extends Error
{}
export default DownloaderError;

View File

@ -1844,7 +1844,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/node@npm:*, @types/node@npm:^20.11.30": "@types/node@npm:*":
version: 20.11.30 version: 20.11.30
resolution: "@types/node@npm:20.11.30" resolution: "@types/node@npm:20.11.30"
dependencies: dependencies:
@ -1853,6 +1853,15 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@types/semver@npm:^7.5.0":
version: 7.5.8 version: 7.5.8
resolution: "@types/semver@npm:7.5.8" resolution: "@types/semver@npm:7.5.8"
@ -3587,7 +3596,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/node": "npm:^20.14.11"
"@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"