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 ` – dodaj utwór do kolejki\n' + '`!fplay ` – 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);