617 lines
24 KiB
JavaScript
617 lines
24 KiB
JavaScript
const { Client, GatewayIntentBits } = require('discord.js');
|
||
const {
|
||
joinVoiceChannel,
|
||
createAudioPlayer,
|
||
createAudioResource,
|
||
AudioPlayerStatus,
|
||
VoiceConnectionStatus,
|
||
entersState,
|
||
StreamType,
|
||
} = require('@discordjs/voice');
|
||
const { spawn } = require('child_process');
|
||
const { PassThrough } = require('stream');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const crypto = require('crypto');
|
||
|
||
// ─── Config ──────────────────────────────────────────────────────────────────
|
||
// SECURITY: never hardcode a bot token. This file previously had one baked in
|
||
// as a fallback — that token is now exposed and should be rotated in the
|
||
// Discord Developer Portal ASAP. Only read from the environment.
|
||
const TOKEN = process.env.DISCORD_TOKEN;
|
||
if (!TOKEN) {
|
||
console.error('❌ Missing DISCORD_TOKEN environment variable.');
|
||
process.exit(1);
|
||
}
|
||
const PREFIX = '!';
|
||
const VOLUME = 0.3; // 0.0 - 1.0
|
||
|
||
// ─── Cache setup ─────────────────────────────────────────────────────────────
|
||
const CACHE_DIR = path.join(__dirname, 'cache');
|
||
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR);
|
||
|
||
// A real encoded opus file is never this small. Anything under this size is
|
||
// treated as a corrupt/truncated download rather than a usable cache entry —
|
||
// used both when finalizing a fresh download and when reading an existing
|
||
// cache file back off disk.
|
||
const MIN_VALID_CACHE_BYTES = 4096;
|
||
|
||
// Startup cleanup pass:
|
||
// - any leftover .tmp files are from downloads that never finished (e.g.
|
||
// the process was killed mid-write) — never valid, always safe to remove.
|
||
// - any .opus file smaller than MIN_VALID_CACHE_BYTES is a corrupt/truncated
|
||
// cache entry that could have been left behind by an older version of
|
||
// this bot (or a crash right at the rename boundary) — remove it so it
|
||
// gets cleanly redownloaded next time it's requested instead of being
|
||
// served as broken audio.
|
||
for (const f of fs.readdirSync(CACHE_DIR)) {
|
||
const full = path.join(CACHE_DIR, f);
|
||
if (f.endsWith('.tmp')) {
|
||
try { fs.unlinkSync(full); } catch {}
|
||
} else if (f.endsWith('.opus')) {
|
||
try {
|
||
if (fs.statSync(full).size < MIN_VALID_CACHE_BYTES) {
|
||
fs.unlinkSync(full);
|
||
console.log(`[Cache] Removed corrupt cache file on startup: ${f}`);
|
||
}
|
||
} catch {}
|
||
}
|
||
}
|
||
|
||
// Metadata cache: maps query/url → { title, duration, url }
|
||
const META_FILE = path.join(CACHE_DIR, 'metadata.json');
|
||
let metaCache = {};
|
||
if (fs.existsSync(META_FILE)) {
|
||
try { metaCache = JSON.parse(fs.readFileSync(META_FILE, 'utf8')); }
|
||
catch { metaCache = {}; }
|
||
}
|
||
|
||
function saveMetaCache() {
|
||
// Write via tmp+rename (same pattern as the audio cache) so a process
|
||
// kill mid-write can never leave metadata.json half-written/corrupt
|
||
// JSON that fails to parse on next boot.
|
||
const tmp = META_FILE + '.tmp';
|
||
try {
|
||
fs.writeFileSync(tmp, JSON.stringify(metaCache, null, 2));
|
||
fs.renameSync(tmp, META_FILE);
|
||
} catch (err) {
|
||
console.error('[Meta] Failed to save metadata cache:', err.message);
|
||
}
|
||
}
|
||
|
||
function getAudioCachePath(url) {
|
||
const hash = crypto.createHash('md5').update(url).digest('hex');
|
||
return path.join(CACHE_DIR, `${hash}.opus`);
|
||
}
|
||
|
||
// A cache file only counts as usable if it exists AND is large enough to be
|
||
// real audio. Anything smaller gets deleted on sight so a corrupt entry is
|
||
// never served twice.
|
||
function isCacheFileValid(cachePath) {
|
||
try {
|
||
return fs.statSync(cachePath).size >= MIN_VALID_CACHE_BYTES;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function evictIfInvalid(cachePath) {
|
||
if (fs.existsSync(cachePath) && !isCacheFileValid(cachePath)) {
|
||
console.warn(`[Cache] Discarding corrupt/empty cache file: ${path.basename(cachePath)}`);
|
||
try { fs.unlinkSync(cachePath); } catch {}
|
||
}
|
||
}
|
||
|
||
// ─── State (per guild) ────────────────────────────────────────────────────────
|
||
const guilds = new Map();
|
||
|
||
function getState(guildId) {
|
||
if (!guilds.has(guildId)) {
|
||
guilds.set(guildId, {
|
||
connection: null,
|
||
player: null,
|
||
queue: [],
|
||
playing: false,
|
||
textChannel: null,
|
||
});
|
||
}
|
||
return guilds.get(guildId);
|
||
}
|
||
|
||
// ─── Download manager ──────────────────────────────────────────────────────
|
||
// Downloads are tracked globally (not per-guild), keyed by URL. This means:
|
||
// - If guild A and guild B both request the same song at the same time,
|
||
// only ONE yt-dlp/ffmpeg pair is spawned; both playbacks tap into it.
|
||
// - If a listener disconnects (skip/leave/stop), the download itself is
|
||
// NOT killed — it keeps writing to disk and completes normally, so the
|
||
// cache is populated correctly for next time instead of ending up with
|
||
// a truncated file.
|
||
// - The cache file only ever appears at its final path once fully written
|
||
// AND large enough to be real audio (write to .tmp, size-check, then
|
||
// atomic rename on success).
|
||
//
|
||
// IMPORTANT — multi-guild fan-out:
|
||
// The shared ffmpeg output is forwarded to the cache file and to every
|
||
// subscribed guild MANUALLY (not via stream.pipe()), and a subscriber's
|
||
// write() return value is ignored. This is deliberate: with .pipe(), Node
|
||
// ties the production rate of the single shared stream to whichever
|
||
// destination is slowest to drain. One guild with a stalled voice
|
||
// connection (or a paused player) would backpressure the shared download
|
||
// and stall the cache write *and every other guild listening to the same
|
||
// song*. Writing manually means a stuck subscriber can only ever hurt
|
||
// itself. Subscribers also swallow their own 'error'/'close' quietly —
|
||
// previously a subscriber being destroyed (e.g. on skip) while still
|
||
// attached to the shared stream could throw an unhandled 'error', which
|
||
// crashes the entire Node process and kills playback in every guild, not
|
||
// just the one that skipped.
|
||
const activeDownloads = new Map(); // url -> DownloadJob
|
||
|
||
class DownloadJob {
|
||
constructor(url) {
|
||
this.url = url;
|
||
this.cachePath = getAudioCachePath(url);
|
||
this.tmpPath = this.cachePath + '.tmp';
|
||
this.subscribers = new Set();
|
||
this.failed = false;
|
||
this._start();
|
||
}
|
||
|
||
_start() {
|
||
const ytdlp = spawn('yt-dlp', [
|
||
'-f', 'bestaudio',
|
||
'--no-playlist',
|
||
'-o', '-',
|
||
'--', this.url,
|
||
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||
|
||
const ffmpeg = spawn('ffmpeg', [
|
||
'-hide_banner', '-loglevel', 'error',
|
||
'-i', 'pipe:0',
|
||
'-af', `volume=${VOLUME}`,
|
||
'-c:a', 'libopus',
|
||
'-b:a', '128k',
|
||
'-f', 'opus',
|
||
'pipe:1',
|
||
], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||
|
||
this.ytdlp = ytdlp;
|
||
this.ffmpeg = ffmpeg;
|
||
|
||
ytdlp.stderr.on('data', (d) => console.error('[yt-dlp]', d.toString().trim()));
|
||
ffmpeg.stderr.on('data', (d) => console.error('[ffmpeg]', d.toString().trim()));
|
||
|
||
// Child-process pipe streams can each emit their own 'error'
|
||
// (e.g. EPIPE when one side of the pipe dies before the other).
|
||
// Without listeners here that's an unhandled exception that
|
||
// crashes the whole bot — this is the fix for downloads breaking
|
||
// whenever multiple guilds/voice channels were involved.
|
||
ytdlp.stdout.on('error', () => {});
|
||
ffmpeg.stdin.on('error', () => {});
|
||
ffmpeg.stdout.on('error', () => {});
|
||
|
||
ytdlp.stdout.pipe(ffmpeg.stdin);
|
||
|
||
ytdlp.on('error', (err) => this._fail(err));
|
||
ffmpeg.on('error', (err) => this._fail(err));
|
||
|
||
ytdlp.on('exit', (code) => {
|
||
if (code !== 0 && code !== null) {
|
||
console.error(`[yt-dlp] exited with code ${code}`);
|
||
try { ffmpeg.stdin.destroy(); } catch {}
|
||
}
|
||
});
|
||
|
||
const cacheStream = fs.createWriteStream(this.tmpPath);
|
||
this.cacheStream = cacheStream;
|
||
cacheStream.on('error', (err) => this._fail(err));
|
||
|
||
// Manual fan-out — see class-level comment for why this replaces
|
||
// ffmpeg.stdout.pipe(...) to the cache file and every subscriber.
|
||
ffmpeg.stdout.on('data', (chunk) => {
|
||
if (this.failed) return;
|
||
if (!cacheStream.destroyed) cacheStream.write(chunk);
|
||
for (const sub of this.subscribers) {
|
||
if (!sub.destroyed) sub.write(chunk);
|
||
}
|
||
});
|
||
|
||
ffmpeg.stdout.on('end', () => {
|
||
if (this.failed) return;
|
||
if (!cacheStream.destroyed) cacheStream.end();
|
||
for (const sub of this.subscribers) {
|
||
if (!sub.destroyed) sub.end();
|
||
}
|
||
});
|
||
|
||
ffmpeg.on('close', (code) => {
|
||
if (code !== 0 && code !== null) {
|
||
this._fail(new Error(`ffmpeg exited with code ${code}`));
|
||
}
|
||
});
|
||
|
||
cacheStream.on('finish', () => {
|
||
if (this.failed) return;
|
||
this._finalizeCache();
|
||
});
|
||
}
|
||
|
||
_finalizeCache() {
|
||
fs.stat(this.tmpPath, (err, stats) => {
|
||
activeDownloads.delete(this.url);
|
||
if (err) {
|
||
console.error('[Cache] Stat failed:', err.message);
|
||
fs.unlink(this.tmpPath, () => {});
|
||
return;
|
||
}
|
||
// Guard against corrupt/empty cache files — e.g. yt-dlp or
|
||
// ffmpeg exited "cleanly" but produced little/no real audio.
|
||
// Never let a file this small become a permanent cache entry.
|
||
if (stats.size < MIN_VALID_CACHE_BYTES) {
|
||
console.error(`[Cache] Discarding suspiciously small file (${stats.size}B): ${this.url}`);
|
||
fs.unlink(this.tmpPath, () => {});
|
||
return;
|
||
}
|
||
fs.rename(this.tmpPath, this.cachePath, (err) => {
|
||
if (err) {
|
||
console.error('[Cache] Rename failed:', err.message);
|
||
} else {
|
||
console.log(`[Cache] Saved: ${path.basename(this.cachePath)}`);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
_fail(err) {
|
||
if (this.failed) return;
|
||
this.failed = true;
|
||
console.error(`[Download] Failed for ${this.url}:`, err.message);
|
||
try { this.ytdlp.kill(); } catch {}
|
||
try { this.ffmpeg.kill(); } catch {}
|
||
try { this.cacheStream.destroy(); } catch {}
|
||
fs.unlink(this.tmpPath, () => {}); // never leave a partial file behind
|
||
for (const sub of this.subscribers) {
|
||
if (!sub.destroyed) sub.destroy(err);
|
||
}
|
||
this.subscribers.clear();
|
||
activeDownloads.delete(this.url);
|
||
}
|
||
|
||
// Each consumer (a guild's playback) gets its own independent tap on
|
||
// the shared download. Destroying this tap (e.g. because that guild
|
||
// skipped/stopped/left) must ONLY remove that one listener — it must
|
||
// never affect the shared child processes, the cache write, or any
|
||
// other guild's tap.
|
||
subscribe() {
|
||
const sub = new PassThrough();
|
||
this.subscribers.add(sub);
|
||
const cleanup = () => this.subscribers.delete(sub);
|
||
sub.on('close', cleanup);
|
||
sub.on('end', cleanup);
|
||
// Swallow subscriber-side errors so a torn-down voice stream can
|
||
// never surface as an unhandled 'error' and crash the process.
|
||
sub.on('error', cleanup);
|
||
return sub;
|
||
}
|
||
}
|
||
|
||
function getOrStartDownload(url) {
|
||
let job = activeDownloads.get(url);
|
||
if (!job) {
|
||
console.log(`[Download] Starting: ${url}`);
|
||
job = new DownloadJob(url);
|
||
activeDownloads.set(url, job);
|
||
} else {
|
||
console.log(`[Download] Joining in-progress download: ${url}`);
|
||
}
|
||
return job;
|
||
}
|
||
|
||
// ─── Discord client ───────────────────────────────────────────────────────────
|
||
const client = new Client({
|
||
intents: [
|
||
GatewayIntentBits.Guilds,
|
||
GatewayIntentBits.GuildVoiceStates,
|
||
GatewayIntentBits.GuildMessages,
|
||
GatewayIntentBits.MessageContent,
|
||
],
|
||
});
|
||
|
||
client.once('ready', () => {
|
||
console.log(`✅ Logged in as ${client.user.tag}`);
|
||
});
|
||
|
||
client.on('messageCreate', async (message) => {
|
||
if (message.author.bot) return;
|
||
if (!message.content.startsWith(PREFIX)) return;
|
||
|
||
const args = message.content.slice(PREFIX.length).trim().split(/ +/);
|
||
const command = args.shift().toLowerCase();
|
||
|
||
try {
|
||
if (command === 'play') {
|
||
const query = args.join(' ');
|
||
if (!query) return message.channel.send('❌ Podaj link lub frazę do wyszukania!');
|
||
await cmdPlay(message, query);
|
||
|
||
} else if (command === 'fplay') {
|
||
const query = args.join(' ');
|
||
if (!query) return message.channel.send('❌ Podaj link lub frazę do wyszukania!');
|
||
await cmdPlay(message, query, { force: true });
|
||
|
||
} else if (command === 'skip') {
|
||
await cmdSkip(message);
|
||
|
||
} else if (command === 'queue') {
|
||
await cmdQueue(message);
|
||
|
||
} else if (command === 'stop') {
|
||
await cmdStop(message);
|
||
|
||
} else if (command === 'leave') {
|
||
await cmdLeave(message);
|
||
|
||
} else if (command === 'help') {
|
||
await message.channel.send(
|
||
'🎵 **Komendy:**\n' +
|
||
'`!play <url/szukaj>` – dodaj utwór do kolejki\n' +
|
||
'`!fplay <url/szukaj>` – wymuś świeże pobranie (ignoruje cache)\n' +
|
||
'`!skip` – pomiń aktualny utwór\n' +
|
||
'`!queue` – pokaż kolejkę\n' +
|
||
'`!stop` – zatrzymaj i wyczyść kolejkę\n' +
|
||
'`!leave` – rozłącz bota'
|
||
);
|
||
}
|
||
} catch (err) {
|
||
console.error(`[ERROR] command=${command}`, err);
|
||
message.channel.send(`⚠️ Błąd: ${err.message}`);
|
||
}
|
||
});
|
||
|
||
// ─── Commands ─────────────────────────────────────────────────────────────────
|
||
|
||
async function cmdPlay(message, query, { force = false } = {}) {
|
||
const voiceChannel = message.member?.voice?.channel;
|
||
if (!voiceChannel) return message.channel.send('❌ Dołącz do kanału głosowego!');
|
||
|
||
const state = getState(message.guildId);
|
||
state.textChannel = message.channel;
|
||
|
||
if (!state.connection ||
|
||
state.connection.state.status === VoiceConnectionStatus.Destroyed ||
|
||
state.connection.state.status === VoiceConnectionStatus.Disconnected) {
|
||
|
||
console.log(`[Voice] Connecting to: ${voiceChannel.name}`);
|
||
|
||
state.connection = joinVoiceChannel({
|
||
channelId: voiceChannel.id,
|
||
guildId: message.guildId,
|
||
adapterCreator: message.guild.voiceAdapterCreator,
|
||
selfDeaf: true,
|
||
});
|
||
|
||
state.connection.on('stateChange', (o, n) => console.log(`[Voice] ${o.status} → ${n.status}`));
|
||
state.connection.on('error', (err) => console.error('[Voice error]', err.message));
|
||
|
||
try {
|
||
await entersState(state.connection, VoiceConnectionStatus.Ready, 30_000);
|
||
console.log('[Voice] Ready!');
|
||
} catch (err) {
|
||
console.error('[Voice] Connect failed:', err.message);
|
||
state.connection.destroy();
|
||
state.connection = null;
|
||
return message.channel.send('❌ Nie udało się połączyć z kanałem głosowym.');
|
||
}
|
||
}
|
||
|
||
if (!state.player) {
|
||
state.player = createAudioPlayer();
|
||
state.connection.subscribe(state.player);
|
||
|
||
state.player.on(AudioPlayerStatus.Idle, () => {
|
||
state.playing = false;
|
||
playNext(message.guildId);
|
||
});
|
||
|
||
state.player.on('error', (err) => {
|
||
console.error('[Player error]', err.message);
|
||
state.playing = false;
|
||
state.textChannel?.send(`⚠️ Błąd odtwarzacza: ${err.message}`);
|
||
playNext(message.guildId);
|
||
});
|
||
}
|
||
|
||
const cacheKey = query.toLowerCase().trim();
|
||
|
||
// Force play skips the metadata-cache shortcut entirely: always do a
|
||
// fresh yt-dlp lookup, and below, always wipe any existing audio cache
|
||
// for the resolved URL so playback can't come from a stale/corrupt file.
|
||
if (!force && metaCache[cacheKey]) {
|
||
const song = metaCache[cacheKey];
|
||
evictIfInvalid(getAudioCachePath(song.url));
|
||
const audioCached = isCacheFileValid(getAudioCachePath(song.url));
|
||
console.log(`[Meta] HIT: "${query}" → ${song.url} (audio cached: ${audioCached})`);
|
||
state.queue.push(song);
|
||
await message.channel.send(
|
||
`✅ ${audioCached ? '⚡ Błyskawicznie z cache: ' : 'Dodano do kolejki: '}**${song.title}** [${song.duration}]`
|
||
);
|
||
if (!state.playing) playNext(message.guildId);
|
||
return;
|
||
}
|
||
|
||
await message.channel.send(force ? `🔁 Wymuszam ponowne pobranie: **${query}**…` : `🔍 Szukam: **${query}**…`);
|
||
const song = await resolveSong(query);
|
||
|
||
metaCache[cacheKey] = song;
|
||
metaCache[song.url.toLowerCase()] = song;
|
||
saveMetaCache();
|
||
console.log(`[Meta] Cached: "${query}" → ${song.url}`);
|
||
|
||
if (force) {
|
||
const cachePath = getAudioCachePath(song.url);
|
||
if (fs.existsSync(cachePath)) {
|
||
try {
|
||
fs.unlinkSync(cachePath);
|
||
console.log(`[Cache] Force-cleared: ${path.basename(cachePath)}`);
|
||
} catch (err) {
|
||
console.error('[Cache] Force-clear failed:', err.message);
|
||
}
|
||
}
|
||
// If a download for this URL happens to already be in flight
|
||
// (e.g. another guild requested it a moment ago), that download is
|
||
// already fresh, not served from cache — buildAudioResource will
|
||
// simply join it like any other cache miss, so nothing more to do.
|
||
}
|
||
|
||
state.queue.push(song);
|
||
await message.channel.send(`✅ Dodano do kolejki: **${song.title}** [${song.duration}]`);
|
||
|
||
if (!state.playing) playNext(message.guildId);
|
||
}
|
||
|
||
async function cmdSkip(message) {
|
||
const state = getState(message.guildId);
|
||
if (!state.playing || !state.player) {
|
||
return message.channel.send('❌ Nic nie jest teraz odtwarzane.');
|
||
}
|
||
state.player.stop();
|
||
await message.channel.send('⏭️ Pominięto utwór.');
|
||
}
|
||
|
||
async function cmdQueue(message) {
|
||
const state = getState(message.guildId);
|
||
if (state.queue.length === 0) {
|
||
return message.channel.send('📭 Kolejka jest pusta.');
|
||
}
|
||
const list = state.queue
|
||
.map((s, i) => `${i + 1}. **${s.title}** [${s.duration}]`)
|
||
.join('\n');
|
||
await message.channel.send(`📜 **Kolejka:**\n${list}`);
|
||
}
|
||
|
||
async function cmdStop(message) {
|
||
const state = getState(message.guildId);
|
||
state.queue = [];
|
||
state.player?.stop();
|
||
state.playing = false;
|
||
await message.channel.send('⏹️ Zatrzymano i wyczyszczono kolejkę.');
|
||
}
|
||
|
||
async function cmdLeave(message) {
|
||
const state = getState(message.guildId);
|
||
state.queue = [];
|
||
state.player?.stop();
|
||
state.playing = false;
|
||
state.connection?.destroy();
|
||
state.connection = null;
|
||
state.player = null;
|
||
await message.channel.send('👋 Rozłączono.');
|
||
}
|
||
|
||
// ─── Playback ─────────────────────────────────────────────────────────────────
|
||
|
||
function playNext(guildId) {
|
||
const state = getState(guildId);
|
||
if (state.queue.length === 0) {
|
||
state.playing = false;
|
||
state.textChannel?.send('📭 Kolejka zakończona.');
|
||
return;
|
||
}
|
||
|
||
const song = state.queue.shift();
|
||
state.playing = true;
|
||
state.textChannel?.send(`▶️ Odtwarzam: **${song.title}** [${song.duration}]`);
|
||
|
||
try {
|
||
const resource = buildAudioResource(song.url);
|
||
state.player.play(resource);
|
||
} catch (err) {
|
||
console.error('[playNext error]', err);
|
||
state.textChannel?.send(`⚠️ Nie można odtworzyć: ${err.message}`);
|
||
state.playing = false;
|
||
playNext(guildId);
|
||
}
|
||
}
|
||
|
||
// ─── Audio resource with caching ─────────────────────────────────────────────
|
||
|
||
function buildAudioResource(url) {
|
||
const cachePath = getAudioCachePath(url);
|
||
|
||
// Cache HIT — instant play from disk, but only if the file is actually
|
||
// large enough to be real audio. Because writes are atomic (tmp file +
|
||
// size-check + rename), a file should only ever exist here if it's
|
||
// complete and valid — this check is a last-line-of-defense safety net
|
||
// in case an older/corrupt file is still sitting on disk.
|
||
if (fs.existsSync(cachePath)) {
|
||
if (isCacheFileValid(cachePath)) {
|
||
console.log(`[Cache] HIT: ${path.basename(cachePath)}`);
|
||
return createAudioResource(fs.createReadStream(cachePath), {
|
||
inputType: StreamType.OggOpus,
|
||
});
|
||
}
|
||
console.warn(`[Cache] Corrupt/empty cache file, discarding and redownloading: ${path.basename(cachePath)}`);
|
||
try { fs.unlinkSync(cachePath); } catch {}
|
||
}
|
||
|
||
// Cache MISS — join (or start) a shared download for this URL.
|
||
// Skipping/stopping playback later will NOT interrupt this download;
|
||
// it keeps running and completes the cache file in the background.
|
||
const job = getOrStartDownload(url);
|
||
const stream = job.subscribe();
|
||
|
||
return createAudioResource(stream, {
|
||
inputType: StreamType.OggOpus,
|
||
});
|
||
}
|
||
|
||
// ─── yt-dlp metadata fetch ────────────────────────────────────────────────────
|
||
|
||
// Dedupe concurrent identical metadata lookups too (e.g. two people paste
|
||
// the same fresh link in the same second) so we don't spawn yt-dlp twice.
|
||
const pendingMetadata = new Map(); // cacheKey/query -> Promise
|
||
|
||
function resolveSong(query) {
|
||
if (pendingMetadata.has(query)) {
|
||
return pendingMetadata.get(query);
|
||
}
|
||
|
||
const isUrl = query.startsWith('http://') || query.startsWith('https://');
|
||
const ytQuery = isUrl ? query : `ytsearch1:${query}`;
|
||
|
||
const promise = new Promise((resolve, reject) => {
|
||
const proc = spawn('yt-dlp', [
|
||
'--no-playlist',
|
||
'--print', '%(title)s|||%(duration_string)s|||%(webpage_url)s',
|
||
'--', ytQuery,
|
||
]);
|
||
|
||
let stdout = '';
|
||
let stderr = '';
|
||
proc.stdout.on('data', (d) => stdout += d.toString());
|
||
proc.stderr.on('data', (d) => stderr += d.toString());
|
||
|
||
proc.on('close', (code) => {
|
||
const line = stdout.split('\n').find(l => l.includes('|||')) || '';
|
||
const parts = line.split('|||');
|
||
if (parts.length >= 3) {
|
||
resolve({
|
||
title: parts[0].trim(),
|
||
duration: parts[1].trim(),
|
||
url: parts[2].trim(),
|
||
});
|
||
} else if (code !== 0) {
|
||
reject(new Error(stderr.trim() || `yt-dlp exited with code ${code}`));
|
||
} else {
|
||
resolve({ title: query, duration: '?:??', url: query });
|
||
}
|
||
});
|
||
}).finally(() => {
|
||
pendingMetadata.delete(query);
|
||
});
|
||
|
||
pendingMetadata.set(query, promise);
|
||
return promise;
|
||
}
|
||
|
||
// ─── Start ────────────────────────────────────────────────────────────────────
|
||
client.login(TOKEN); |