Upload files to "/"
This commit is contained in:
@@ -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 <url/szukaj>` – 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);
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user