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); // 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 } 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() { fs.writeFileSync(META_FILE, JSON.stringify(metaCache, null, 2)); } function getAudioCachePath(url) { const hash = crypto.createHash('md5').update(url).digest('hex'); return path.join(CACHE_DIR, `${hash}.opus`); } // ─── 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 // (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: [ 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 === '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' + '`!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) { 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(); if (metaCache[cacheKey]) { const song = metaCache[cacheKey]; const audioCached = fs.existsSync(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(`🔍 Szukam: **${query}**…`); const song = await resolveSong(query); metaCache[cacheKey] = song; metaCache[song.url.toLowerCase()] = song; saveMetaCache(); console.log(`[Meta] Cached: "${query}" → ${song.url}`); 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. 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), { inputType: StreamType.OggOpus, }); } // 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);