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);
|
||||
Reference in New Issue
Block a user