Update index.js
This commit is contained in:
@@ -15,7 +15,14 @@ const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||
const TOKEN = process.env.DISCORD_TOKEN || '';
|
||||
// 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
|
||||
|
||||
@@ -23,8 +30,16 @@ const VOLUME = 0.3; // 0.0 - 1.0
|
||||
const CACHE_DIR = path.join(__dirname, 'cache');
|
||||
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR);
|
||||
|
||||
// On startup, remove any leftover .tmp files from downloads that never
|
||||
// finished (e.g. the process was killed mid-write). These are never valid
|
||||
// cache entries, so there's no reason to keep them around.
|
||||
for (const f of fs.readdirSync(CACHE_DIR)) {
|
||||
if (f.endsWith('.tmp')) {
|
||||
try { fs.unlinkSync(path.join(CACHE_DIR, f)); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata cache: maps query/url → { title, duration, url }
|
||||
// Saved to disk so it survives bot restarts
|
||||
const META_FILE = path.join(CACHE_DIR, 'metadata.json');
|
||||
let metaCache = {};
|
||||
if (fs.existsSync(META_FILE)) {
|
||||
@@ -57,6 +72,130 @@ function getState(guildId) {
|
||||
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
|
||||
// (write to .tmp, then atomic rename on success).
|
||||
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.source = new PassThrough(); // fan-out: ffmpeg output -> all subscribers + cache file
|
||||
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()));
|
||||
|
||||
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;
|
||||
|
||||
ffmpeg.stdout.pipe(this.source);
|
||||
this.source.pipe(cacheStream);
|
||||
|
||||
ffmpeg.on('close', (code) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
this._fail(new Error(`ffmpeg exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
cacheStream.on('error', (err) => this._fail(err));
|
||||
|
||||
cacheStream.on('finish', () => {
|
||||
if (this.failed) return; // already handled via _fail cleanup
|
||||
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)}`);
|
||||
}
|
||||
activeDownloads.delete(this.url);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_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) sub.destroy(err);
|
||||
activeDownloads.delete(this.url);
|
||||
}
|
||||
|
||||
// Each consumer (a guild's playback) gets its own tap on the shared
|
||||
// source. Closing this tap (e.g. because the guild skipped the track)
|
||||
// only removes that listener — it does not affect the download itself.
|
||||
subscribe() {
|
||||
const sub = new PassThrough();
|
||||
this.subscribers.add(sub);
|
||||
this.source.pipe(sub);
|
||||
const cleanup = () => this.subscribers.delete(sub);
|
||||
sub.on('close', cleanup);
|
||||
sub.on('end', 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: [
|
||||
@@ -121,7 +260,6 @@ async function cmdPlay(message, query) {
|
||||
const state = getState(message.guildId);
|
||||
state.textChannel = message.channel;
|
||||
|
||||
// Join voice if not already connected
|
||||
if (!state.connection ||
|
||||
state.connection.state.status === VoiceConnectionStatus.Destroyed ||
|
||||
state.connection.state.status === VoiceConnectionStatus.Disconnected) {
|
||||
@@ -149,7 +287,6 @@ async function cmdPlay(message, query) {
|
||||
}
|
||||
}
|
||||
|
||||
// Create player if needed
|
||||
if (!state.player) {
|
||||
state.player = createAudioPlayer();
|
||||
state.connection.subscribe(state.player);
|
||||
@@ -167,7 +304,6 @@ async function cmdPlay(message, query) {
|
||||
});
|
||||
}
|
||||
|
||||
// Check metadata cache first — if known, skip the yt-dlp lookup entirely
|
||||
const cacheKey = query.toLowerCase().trim();
|
||||
if (metaCache[cacheKey]) {
|
||||
const song = metaCache[cacheKey];
|
||||
@@ -181,11 +317,9 @@ async function cmdPlay(message, query) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Not in cache — fetch metadata with yt-dlp
|
||||
await message.channel.send(`🔍 Szukam: **${query}**…`);
|
||||
const song = await resolveSong(query);
|
||||
|
||||
// Save to metadata cache under both the original query and the resolved URL
|
||||
metaCache[cacheKey] = song;
|
||||
metaCache[song.url.toLowerCase()] = song;
|
||||
saveMetaCache();
|
||||
@@ -266,7 +400,8 @@ function playNext(guildId) {
|
||||
function buildAudioResource(url) {
|
||||
const cachePath = getAudioCachePath(url);
|
||||
|
||||
// Audio cache HIT — instant play from disk
|
||||
// Cache HIT — instant play from disk. Because writes are atomic
|
||||
// (tmp file + rename), a file only exists here if it's complete.
|
||||
if (fs.existsSync(cachePath)) {
|
||||
console.log(`[Cache] HIT: ${path.basename(cachePath)}`);
|
||||
return createAudioResource(fs.createReadStream(cachePath), {
|
||||
@@ -274,70 +409,32 @@ function buildAudioResource(url) {
|
||||
});
|
||||
}
|
||||
|
||||
// Audio cache MISS — stream + save to disk simultaneously
|
||||
console.log(`[Cache] MISS: downloading ${url}`);
|
||||
// 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();
|
||||
|
||||
const ytdlp = spawn('yt-dlp', [
|
||||
'-f', 'bestaudio',
|
||||
'--no-playlist',
|
||||
'-o', '-',
|
||||
'--', url,
|
||||
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
|
||||
ytdlp.stderr.on('data', (d) => console.error('[yt-dlp]', d.toString().trim()));
|
||||
|
||||
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'] });
|
||||
|
||||
ffmpeg.stderr.on('data', (d) => console.error('[ffmpeg]', d.toString().trim()));
|
||||
|
||||
ytdlp.stdout.pipe(ffmpeg.stdin);
|
||||
ytdlp.on('exit', (code) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
console.error(`[yt-dlp] exited with code ${code}`);
|
||||
ffmpeg.stdin.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Tee: Discord stream AND cache file at the same time
|
||||
const tee = new PassThrough();
|
||||
const cacheStream = fs.createWriteStream(cachePath);
|
||||
|
||||
ffmpeg.stdout.pipe(tee);
|
||||
tee.pipe(cacheStream);
|
||||
|
||||
cacheStream.on('finish', () => console.log(`[Cache] Saved: ${path.basename(cachePath)}`));
|
||||
cacheStream.on('error', (err) => {
|
||||
console.error('[Cache] Write error:', err.message);
|
||||
if (fs.existsSync(cachePath)) fs.unlinkSync(cachePath);
|
||||
});
|
||||
|
||||
const resource = createAudioResource(tee, {
|
||||
return createAudioResource(stream, {
|
||||
inputType: StreamType.OggOpus,
|
||||
});
|
||||
|
||||
resource.playStream.on('close', () => {
|
||||
if (!ytdlp.killed) ytdlp.kill();
|
||||
if (!ffmpeg.killed) ffmpeg.kill();
|
||||
});
|
||||
|
||||
return resource;
|
||||
}
|
||||
|
||||
// ─── 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}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
const proc = spawn('yt-dlp', [
|
||||
'--no-playlist',
|
||||
'--print', '%(title)s|||%(duration_string)s|||%(webpage_url)s',
|
||||
@@ -364,7 +461,12 @@ function resolveSong(query) {
|
||||
resolve({ title: query, duration: '?:??', url: query });
|
||||
}
|
||||
});
|
||||
}).finally(() => {
|
||||
pendingMetadata.delete(query);
|
||||
});
|
||||
|
||||
pendingMetadata.set(query, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
// ─── Start ────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user