commit 3fd9a25135909386c93412d8c6273ca99311a954 Author: kq Date: Wed Aug 5 14:35:21 2026 +0000 Upload files to "/" diff --git a/index.js b/index.js new file mode 100644 index 0000000..1f427e3 --- /dev/null +++ b/index.js @@ -0,0 +1,371 @@ +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); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..1f7455b --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "discord-music-bot", + "version": "1.0.0", + "description": "Discord music bot with yt-dlp and DAVE E2EE support", + "main": "index.js", + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "@discordjs/voice": "^0.19.2", + "@snazzah/davey": "^0.1.11", + "discord.js": "^14.18.0", + "opusscript": "^0.1.1", + "sodium-native": "latest" + } +}