Update index.js

This commit is contained in:
kq
2026-08-08 16:44:05 +00:00
parent 56272c3090
commit 56e67eb151
+163 -61
View File
@@ -15,7 +15,14 @@ const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
// ─── Config ────────────────────────────────────────────────────────────────── // ─── 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 PREFIX = '!';
const VOLUME = 0.3; // 0.0 - 1.0 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'); const CACHE_DIR = path.join(__dirname, 'cache');
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR); 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 } // 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'); const META_FILE = path.join(CACHE_DIR, 'metadata.json');
let metaCache = {}; let metaCache = {};
if (fs.existsSync(META_FILE)) { if (fs.existsSync(META_FILE)) {
@@ -57,6 +72,130 @@ function getState(guildId) {
return guilds.get(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 ─────────────────────────────────────────────────────────── // ─── Discord client ───────────────────────────────────────────────────────────
const client = new Client({ const client = new Client({
intents: [ intents: [
@@ -121,7 +260,6 @@ async function cmdPlay(message, query) {
const state = getState(message.guildId); const state = getState(message.guildId);
state.textChannel = message.channel; state.textChannel = message.channel;
// Join voice if not already connected
if (!state.connection || if (!state.connection ||
state.connection.state.status === VoiceConnectionStatus.Destroyed || state.connection.state.status === VoiceConnectionStatus.Destroyed ||
state.connection.state.status === VoiceConnectionStatus.Disconnected) { state.connection.state.status === VoiceConnectionStatus.Disconnected) {
@@ -149,7 +287,6 @@ async function cmdPlay(message, query) {
} }
} }
// Create player if needed
if (!state.player) { if (!state.player) {
state.player = createAudioPlayer(); state.player = createAudioPlayer();
state.connection.subscribe(state.player); 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(); const cacheKey = query.toLowerCase().trim();
if (metaCache[cacheKey]) { if (metaCache[cacheKey]) {
const song = metaCache[cacheKey]; const song = metaCache[cacheKey];
@@ -181,11 +317,9 @@ async function cmdPlay(message, query) {
return; return;
} }
// Not in cache — fetch metadata with yt-dlp
await message.channel.send(`🔍 Szukam: **${query}**…`); await message.channel.send(`🔍 Szukam: **${query}**…`);
const song = await resolveSong(query); const song = await resolveSong(query);
// Save to metadata cache under both the original query and the resolved URL
metaCache[cacheKey] = song; metaCache[cacheKey] = song;
metaCache[song.url.toLowerCase()] = song; metaCache[song.url.toLowerCase()] = song;
saveMetaCache(); saveMetaCache();
@@ -266,7 +400,8 @@ function playNext(guildId) {
function buildAudioResource(url) { function buildAudioResource(url) {
const cachePath = getAudioCachePath(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)) { if (fs.existsSync(cachePath)) {
console.log(`[Cache] HIT: ${path.basename(cachePath)}`); console.log(`[Cache] HIT: ${path.basename(cachePath)}`);
return createAudioResource(fs.createReadStream(cachePath), { return createAudioResource(fs.createReadStream(cachePath), {
@@ -274,70 +409,32 @@ function buildAudioResource(url) {
}); });
} }
// Audio cache MISS — stream + save to disk simultaneously // Cache MISS — join (or start) a shared download for this URL.
console.log(`[Cache] MISS: downloading ${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', [ return createAudioResource(stream, {
'-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, {
inputType: StreamType.OggOpus, inputType: StreamType.OggOpus,
}); });
resource.playStream.on('close', () => {
if (!ytdlp.killed) ytdlp.kill();
if (!ffmpeg.killed) ffmpeg.kill();
});
return resource;
} }
// ─── yt-dlp metadata fetch ──────────────────────────────────────────────────── // ─── 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) { function resolveSong(query) {
if (pendingMetadata.has(query)) {
return pendingMetadata.get(query);
}
const isUrl = query.startsWith('http://') || query.startsWith('https://'); const isUrl = query.startsWith('http://') || query.startsWith('https://');
const ytQuery = isUrl ? query : `ytsearch1:${query}`; const ytQuery = isUrl ? query : `ytsearch1:${query}`;
return new Promise((resolve, reject) => { const promise = new Promise((resolve, reject) => {
const proc = spawn('yt-dlp', [ const proc = spawn('yt-dlp', [
'--no-playlist', '--no-playlist',
'--print', '%(title)s|||%(duration_string)s|||%(webpage_url)s', '--print', '%(title)s|||%(duration_string)s|||%(webpage_url)s',
@@ -364,7 +461,12 @@ function resolveSong(query) {
resolve({ title: query, duration: '?:??', url: query }); resolve({ title: query, duration: '?:??', url: query });
} }
}); });
}).finally(() => {
pendingMetadata.delete(query);
}); });
pendingMetadata.set(query, promise);
return promise;
} }
// ─── Start ──────────────────────────────────────────────────────────────────── // ─── Start ────────────────────────────────────────────────────────────────────