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 ────────────────────────────────────────────────────────────────── const TOKEN = process.env.DISCORD_TOKEN || 'MTI4NzgyMDY5MjY2MTg2MjQ3MQ.GT9ssR.TXNSDgclq8JcLYpGcE8YUEqQy99Dg7jvW4hTQA'; 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); // 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)) { 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); } // ─── 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; // Join voice if not already connected 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.'); } } // Create player if needed 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); }); } // Check metadata cache first — if known, skip the yt-dlp lookup entirely 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; } // 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(); 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); // Audio cache HIT — instant play from disk if (fs.existsSync(cachePath)) { console.log(`[Cache] HIT: ${path.basename(cachePath)}`); return createAudioResource(fs.createReadStream(cachePath), { inputType: StreamType.OggOpus, }); } // Audio cache MISS — stream + save to disk simultaneously console.log(`[Cache] MISS: downloading ${url}`); 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, { inputType: StreamType.OggOpus, }); resource.playStream.on('close', () => { if (!ytdlp.killed) ytdlp.kill(); if (!ffmpeg.killed) ffmpeg.kill(); }); return resource; } // ─── yt-dlp metadata fetch ──────────────────────────────────────────────────── function resolveSong(query) { const isUrl = query.startsWith('http://') || query.startsWith('https://'); const ytQuery = isUrl ? query : `ytsearch1:${query}`; return 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 }); } }); }); } // ─── Start ──────────────────────────────────────────────────────────────────── client.login(TOKEN);