Update index.js
This commit is contained in:
@@ -15,7 +15,14 @@ const path = require('path');
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||||
const TOKEN = process.env.DISCORD_TOKEN || '';
|
// 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 PREFIX = '!';
|
||||||
const VOLUME = 0.3; // 0.0 - 1.0
|
const VOLUME = 0.3; // 0.0 - 1.0
|
||||||
|
|
||||||
@@ -23,8 +30,16 @@ const VOLUME = 0.3; // 0.0 - 1.0
|
|||||||
const CACHE_DIR = path.join(__dirname, 'cache');
|
const CACHE_DIR = path.join(__dirname, 'cache');
|
||||||
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR);
|
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 }
|
// 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');
|
const META_FILE = path.join(CACHE_DIR, 'metadata.json');
|
||||||
let metaCache = {};
|
let metaCache = {};
|
||||||
if (fs.existsSync(META_FILE)) {
|
if (fs.existsSync(META_FILE)) {
|
||||||
@@ -57,6 +72,130 @@ function getState(guildId) {
|
|||||||
return guilds.get(guildId);
|
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 ───────────────────────────────────────────────────────────
|
// ─── Discord client ───────────────────────────────────────────────────────────
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
intents: [
|
intents: [
|
||||||
@@ -121,7 +260,6 @@ async function cmdPlay(message, query) {
|
|||||||
const state = getState(message.guildId);
|
const state = getState(message.guildId);
|
||||||
state.textChannel = message.channel;
|
state.textChannel = message.channel;
|
||||||
|
|
||||||
// Join voice if not already connected
|
|
||||||
if (!state.connection ||
|
if (!state.connection ||
|
||||||
state.connection.state.status === VoiceConnectionStatus.Destroyed ||
|
state.connection.state.status === VoiceConnectionStatus.Destroyed ||
|
||||||
state.connection.state.status === VoiceConnectionStatus.Disconnected) {
|
state.connection.state.status === VoiceConnectionStatus.Disconnected) {
|
||||||
@@ -149,7 +287,6 @@ async function cmdPlay(message, query) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create player if needed
|
|
||||||
if (!state.player) {
|
if (!state.player) {
|
||||||
state.player = createAudioPlayer();
|
state.player = createAudioPlayer();
|
||||||
state.connection.subscribe(state.player);
|
state.connection.subscribe(state.player);
|
||||||
@@ -167,7 +304,6 @@ async function cmdPlay(message, query) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check metadata cache first — if known, skip the yt-dlp lookup entirely
|
|
||||||
const cacheKey = query.toLowerCase().trim();
|
const cacheKey = query.toLowerCase().trim();
|
||||||
if (metaCache[cacheKey]) {
|
if (metaCache[cacheKey]) {
|
||||||
const song = metaCache[cacheKey];
|
const song = metaCache[cacheKey];
|
||||||
@@ -181,11 +317,9 @@ async function cmdPlay(message, query) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not in cache — fetch metadata with yt-dlp
|
|
||||||
await message.channel.send(`🔍 Szukam: **${query}**…`);
|
await message.channel.send(`🔍 Szukam: **${query}**…`);
|
||||||
const song = await resolveSong(query);
|
const song = await resolveSong(query);
|
||||||
|
|
||||||
// Save to metadata cache under both the original query and the resolved URL
|
|
||||||
metaCache[cacheKey] = song;
|
metaCache[cacheKey] = song;
|
||||||
metaCache[song.url.toLowerCase()] = song;
|
metaCache[song.url.toLowerCase()] = song;
|
||||||
saveMetaCache();
|
saveMetaCache();
|
||||||
@@ -266,7 +400,8 @@ function playNext(guildId) {
|
|||||||
function buildAudioResource(url) {
|
function buildAudioResource(url) {
|
||||||
const cachePath = getAudioCachePath(url);
|
const cachePath = getAudioCachePath(url);
|
||||||
|
|
||||||
// Audio cache HIT — instant play from disk
|
// 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)) {
|
if (fs.existsSync(cachePath)) {
|
||||||
console.log(`[Cache] HIT: ${path.basename(cachePath)}`);
|
console.log(`[Cache] HIT: ${path.basename(cachePath)}`);
|
||||||
return createAudioResource(fs.createReadStream(cachePath), {
|
return createAudioResource(fs.createReadStream(cachePath), {
|
||||||
@@ -274,70 +409,32 @@ function buildAudioResource(url) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio cache MISS — stream + save to disk simultaneously
|
// Cache MISS — join (or start) a shared download for this URL.
|
||||||
console.log(`[Cache] MISS: downloading ${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();
|
||||||
|
|
||||||
const ytdlp = spawn('yt-dlp', [
|
return createAudioResource(stream, {
|
||||||
'-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,
|
inputType: StreamType.OggOpus,
|
||||||
});
|
});
|
||||||
|
|
||||||
resource.playStream.on('close', () => {
|
|
||||||
if (!ytdlp.killed) ytdlp.kill();
|
|
||||||
if (!ffmpeg.killed) ffmpeg.kill();
|
|
||||||
});
|
|
||||||
|
|
||||||
return resource;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── yt-dlp metadata fetch ────────────────────────────────────────────────────
|
// ─── 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) {
|
function resolveSong(query) {
|
||||||
|
if (pendingMetadata.has(query)) {
|
||||||
|
return pendingMetadata.get(query);
|
||||||
|
}
|
||||||
|
|
||||||
const isUrl = query.startsWith('http://') || query.startsWith('https://');
|
const isUrl = query.startsWith('http://') || query.startsWith('https://');
|
||||||
const ytQuery = isUrl ? query : `ytsearch1:${query}`;
|
const ytQuery = isUrl ? query : `ytsearch1:${query}`;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
const promise = new Promise((resolve, reject) => {
|
||||||
const proc = spawn('yt-dlp', [
|
const proc = spawn('yt-dlp', [
|
||||||
'--no-playlist',
|
'--no-playlist',
|
||||||
'--print', '%(title)s|||%(duration_string)s|||%(webpage_url)s',
|
'--print', '%(title)s|||%(duration_string)s|||%(webpage_url)s',
|
||||||
@@ -364,7 +461,12 @@ function resolveSong(query) {
|
|||||||
resolve({ title: query, duration: '?:??', url: query });
|
resolve({ title: query, duration: '?:??', url: query });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}).finally(() => {
|
||||||
|
pendingMetadata.delete(query);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pendingMetadata.set(query, promise);
|
||||||
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Start ────────────────────────────────────────────────────────────────────
|
// ─── Start ────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user