Update index.js
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
require('dotenv').config();
|
||||||
const { Client, GatewayIntentBits } = require('discord.js');
|
const { Client, GatewayIntentBits } = require('discord.js');
|
||||||
const {
|
const {
|
||||||
joinVoiceChannel,
|
joinVoiceChannel,
|
||||||
@@ -15,9 +16,6 @@ const path = require('path');
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||||
// 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;
|
const TOKEN = process.env.DISCORD_TOKEN;
|
||||||
if (!TOKEN) {
|
if (!TOKEN) {
|
||||||
console.error('❌ Missing DISCORD_TOKEN environment variable.');
|
console.error('❌ Missing DISCORD_TOKEN environment variable.');
|
||||||
@@ -26,24 +24,15 @@ if (!TOKEN) {
|
|||||||
const PREFIX = '!';
|
const PREFIX = '!';
|
||||||
const VOLUME = 0.3; // 0.0 - 1.0
|
const VOLUME = 0.3; // 0.0 - 1.0
|
||||||
|
|
||||||
|
// Cookies: set to '' to disable, or e.g. 'firefox', 'firefox:default', etc.
|
||||||
|
const YTDL_COOKIES_FROM_BROWSER = 'firefox'; // <-- add/change this line only
|
||||||
|
|
||||||
// ─── Cache setup ─────────────────────────────────────────────────────────────
|
// ─── Cache setup ─────────────────────────────────────────────────────────────
|
||||||
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);
|
||||||
|
|
||||||
// A real encoded opus file is never this small. Anything under this size is
|
|
||||||
// treated as a corrupt/truncated download rather than a usable cache entry —
|
|
||||||
// used both when finalizing a fresh download and when reading an existing
|
|
||||||
// cache file back off disk.
|
|
||||||
const MIN_VALID_CACHE_BYTES = 4096;
|
const MIN_VALID_CACHE_BYTES = 4096;
|
||||||
|
|
||||||
// Startup cleanup pass:
|
|
||||||
// - any leftover .tmp files are from downloads that never finished (e.g.
|
|
||||||
// the process was killed mid-write) — never valid, always safe to remove.
|
|
||||||
// - any .opus file smaller than MIN_VALID_CACHE_BYTES is a corrupt/truncated
|
|
||||||
// cache entry that could have been left behind by an older version of
|
|
||||||
// this bot (or a crash right at the rename boundary) — remove it so it
|
|
||||||
// gets cleanly redownloaded next time it's requested instead of being
|
|
||||||
// served as broken audio.
|
|
||||||
for (const f of fs.readdirSync(CACHE_DIR)) {
|
for (const f of fs.readdirSync(CACHE_DIR)) {
|
||||||
const full = path.join(CACHE_DIR, f);
|
const full = path.join(CACHE_DIR, f);
|
||||||
if (f.endsWith('.tmp')) {
|
if (f.endsWith('.tmp')) {
|
||||||
@@ -58,7 +47,6 @@ for (const f of fs.readdirSync(CACHE_DIR)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metadata cache: maps query/url → { title, duration, url }
|
|
||||||
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)) {
|
||||||
@@ -67,9 +55,6 @@ if (fs.existsSync(META_FILE)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function saveMetaCache() {
|
function saveMetaCache() {
|
||||||
// Write via tmp+rename (same pattern as the audio cache) so a process
|
|
||||||
// kill mid-write can never leave metadata.json half-written/corrupt
|
|
||||||
// JSON that fails to parse on next boot.
|
|
||||||
const tmp = META_FILE + '.tmp';
|
const tmp = META_FILE + '.tmp';
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(tmp, JSON.stringify(metaCache, null, 2));
|
fs.writeFileSync(tmp, JSON.stringify(metaCache, null, 2));
|
||||||
@@ -84,9 +69,6 @@ function getAudioCachePath(url) {
|
|||||||
return path.join(CACHE_DIR, `${hash}.opus`);
|
return path.join(CACHE_DIR, `${hash}.opus`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A cache file only counts as usable if it exists AND is large enough to be
|
|
||||||
// real audio. Anything smaller gets deleted on sight so a corrupt entry is
|
|
||||||
// never served twice.
|
|
||||||
function isCacheFileValid(cachePath) {
|
function isCacheFileValid(cachePath) {
|
||||||
try {
|
try {
|
||||||
return fs.statSync(cachePath).size >= MIN_VALID_CACHE_BYTES;
|
return fs.statSync(cachePath).size >= MIN_VALID_CACHE_BYTES;
|
||||||
@@ -119,32 +101,7 @@ function getState(guildId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Download manager ──────────────────────────────────────────────────────
|
// ─── Download manager ──────────────────────────────────────────────────────
|
||||||
// Downloads are tracked globally (not per-guild), keyed by URL. This means:
|
const activeDownloads = new Map();
|
||||||
// - 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
|
|
||||||
// AND large enough to be real audio (write to .tmp, size-check, then
|
|
||||||
// atomic rename on success).
|
|
||||||
//
|
|
||||||
// IMPORTANT — multi-guild fan-out:
|
|
||||||
// The shared ffmpeg output is forwarded to the cache file and to every
|
|
||||||
// subscribed guild MANUALLY (not via stream.pipe()), and a subscriber's
|
|
||||||
// write() return value is ignored. This is deliberate: with .pipe(), Node
|
|
||||||
// ties the production rate of the single shared stream to whichever
|
|
||||||
// destination is slowest to drain. One guild with a stalled voice
|
|
||||||
// connection (or a paused player) would backpressure the shared download
|
|
||||||
// and stall the cache write *and every other guild listening to the same
|
|
||||||
// song*. Writing manually means a stuck subscriber can only ever hurt
|
|
||||||
// itself. Subscribers also swallow their own 'error'/'close' quietly —
|
|
||||||
// previously a subscriber being destroyed (e.g. on skip) while still
|
|
||||||
// attached to the shared stream could throw an unhandled 'error', which
|
|
||||||
// crashes the entire Node process and kills playback in every guild, not
|
|
||||||
// just the one that skipped.
|
|
||||||
const activeDownloads = new Map(); // url -> DownloadJob
|
|
||||||
|
|
||||||
class DownloadJob {
|
class DownloadJob {
|
||||||
constructor(url) {
|
constructor(url) {
|
||||||
@@ -157,12 +114,22 @@ class DownloadJob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_start() {
|
_start() {
|
||||||
const ytdlp = spawn('yt-dlp', [
|
const ytdlpArgs = [
|
||||||
'-f', 'bestaudio',
|
'-f', 'ba/b', // <-- changed from 'bestaudio'
|
||||||
'--no-playlist',
|
'--no-playlist',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (YTDL_COOKIES_FROM_BROWSER) {
|
||||||
|
ytdlpArgs.push('--cookies-from-browser', YTDL_COOKIES_FROM_BROWSER);
|
||||||
|
}
|
||||||
|
|
||||||
|
ytdlpArgs.push(
|
||||||
'-o', '-',
|
'-o', '-',
|
||||||
'--', this.url,
|
'--',
|
||||||
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
this.url
|
||||||
|
);
|
||||||
|
|
||||||
|
const ytdlp = spawn('yt-dlp', ytdlpArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
|
||||||
const ffmpeg = spawn('ffmpeg', [
|
const ffmpeg = spawn('ffmpeg', [
|
||||||
'-hide_banner', '-loglevel', 'error',
|
'-hide_banner', '-loglevel', 'error',
|
||||||
@@ -180,11 +147,6 @@ class DownloadJob {
|
|||||||
ytdlp.stderr.on('data', (d) => console.error('[yt-dlp]', d.toString().trim()));
|
ytdlp.stderr.on('data', (d) => console.error('[yt-dlp]', d.toString().trim()));
|
||||||
ffmpeg.stderr.on('data', (d) => console.error('[ffmpeg]', d.toString().trim()));
|
ffmpeg.stderr.on('data', (d) => console.error('[ffmpeg]', d.toString().trim()));
|
||||||
|
|
||||||
// Child-process pipe streams can each emit their own 'error'
|
|
||||||
// (e.g. EPIPE when one side of the pipe dies before the other).
|
|
||||||
// Without listeners here that's an unhandled exception that
|
|
||||||
// crashes the whole bot — this is the fix for downloads breaking
|
|
||||||
// whenever multiple guilds/voice channels were involved.
|
|
||||||
ytdlp.stdout.on('error', () => {});
|
ytdlp.stdout.on('error', () => {});
|
||||||
ffmpeg.stdin.on('error', () => {});
|
ffmpeg.stdin.on('error', () => {});
|
||||||
ffmpeg.stdout.on('error', () => {});
|
ffmpeg.stdout.on('error', () => {});
|
||||||
@@ -205,8 +167,6 @@ class DownloadJob {
|
|||||||
this.cacheStream = cacheStream;
|
this.cacheStream = cacheStream;
|
||||||
cacheStream.on('error', (err) => this._fail(err));
|
cacheStream.on('error', (err) => this._fail(err));
|
||||||
|
|
||||||
// Manual fan-out — see class-level comment for why this replaces
|
|
||||||
// ffmpeg.stdout.pipe(...) to the cache file and every subscriber.
|
|
||||||
ffmpeg.stdout.on('data', (chunk) => {
|
ffmpeg.stdout.on('data', (chunk) => {
|
||||||
if (this.failed) return;
|
if (this.failed) return;
|
||||||
if (!cacheStream.destroyed) cacheStream.write(chunk);
|
if (!cacheStream.destroyed) cacheStream.write(chunk);
|
||||||
@@ -243,9 +203,6 @@ class DownloadJob {
|
|||||||
fs.unlink(this.tmpPath, () => {});
|
fs.unlink(this.tmpPath, () => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Guard against corrupt/empty cache files — e.g. yt-dlp or
|
|
||||||
// ffmpeg exited "cleanly" but produced little/no real audio.
|
|
||||||
// Never let a file this small become a permanent cache entry.
|
|
||||||
if (stats.size < MIN_VALID_CACHE_BYTES) {
|
if (stats.size < MIN_VALID_CACHE_BYTES) {
|
||||||
console.error(`[Cache] Discarding suspiciously small file (${stats.size}B): ${this.url}`);
|
console.error(`[Cache] Discarding suspiciously small file (${stats.size}B): ${this.url}`);
|
||||||
fs.unlink(this.tmpPath, () => {});
|
fs.unlink(this.tmpPath, () => {});
|
||||||
@@ -268,7 +225,7 @@ class DownloadJob {
|
|||||||
try { this.ytdlp.kill(); } catch {}
|
try { this.ytdlp.kill(); } catch {}
|
||||||
try { this.ffmpeg.kill(); } catch {}
|
try { this.ffmpeg.kill(); } catch {}
|
||||||
try { this.cacheStream.destroy(); } catch {}
|
try { this.cacheStream.destroy(); } catch {}
|
||||||
fs.unlink(this.tmpPath, () => {}); // never leave a partial file behind
|
fs.unlink(this.tmpPath, () => {});
|
||||||
for (const sub of this.subscribers) {
|
for (const sub of this.subscribers) {
|
||||||
if (!sub.destroyed) sub.destroy(err);
|
if (!sub.destroyed) sub.destroy(err);
|
||||||
}
|
}
|
||||||
@@ -276,19 +233,12 @@ class DownloadJob {
|
|||||||
activeDownloads.delete(this.url);
|
activeDownloads.delete(this.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each consumer (a guild's playback) gets its own independent tap on
|
|
||||||
// the shared download. Destroying this tap (e.g. because that guild
|
|
||||||
// skipped/stopped/left) must ONLY remove that one listener — it must
|
|
||||||
// never affect the shared child processes, the cache write, or any
|
|
||||||
// other guild's tap.
|
|
||||||
subscribe() {
|
subscribe() {
|
||||||
const sub = new PassThrough();
|
const sub = new PassThrough();
|
||||||
this.subscribers.add(sub);
|
this.subscribers.add(sub);
|
||||||
const cleanup = () => this.subscribers.delete(sub);
|
const cleanup = () => this.subscribers.delete(sub);
|
||||||
sub.on('close', cleanup);
|
sub.on('close', cleanup);
|
||||||
sub.on('end', cleanup);
|
sub.on('end', cleanup);
|
||||||
// Swallow subscriber-side errors so a torn-down voice stream can
|
|
||||||
// never surface as an unhandled 'error' and crash the process.
|
|
||||||
sub.on('error', cleanup);
|
sub.on('error', cleanup);
|
||||||
return sub;
|
return sub;
|
||||||
}
|
}
|
||||||
@@ -422,9 +372,6 @@ async function cmdPlay(message, query, { force = false } = {}) {
|
|||||||
|
|
||||||
const cacheKey = query.toLowerCase().trim();
|
const cacheKey = query.toLowerCase().trim();
|
||||||
|
|
||||||
// Force play skips the metadata-cache shortcut entirely: always do a
|
|
||||||
// fresh yt-dlp lookup, and below, always wipe any existing audio cache
|
|
||||||
// for the resolved URL so playback can't come from a stale/corrupt file.
|
|
||||||
if (!force && metaCache[cacheKey]) {
|
if (!force && metaCache[cacheKey]) {
|
||||||
const song = metaCache[cacheKey];
|
const song = metaCache[cacheKey];
|
||||||
evictIfInvalid(getAudioCachePath(song.url));
|
evictIfInvalid(getAudioCachePath(song.url));
|
||||||
@@ -456,10 +403,6 @@ async function cmdPlay(message, query, { force = false } = {}) {
|
|||||||
console.error('[Cache] Force-clear failed:', err.message);
|
console.error('[Cache] Force-clear failed:', err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If a download for this URL happens to already be in flight
|
|
||||||
// (e.g. another guild requested it a moment ago), that download is
|
|
||||||
// already fresh, not served from cache — buildAudioResource will
|
|
||||||
// simply join it like any other cache miss, so nothing more to do.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
state.queue.push(song);
|
state.queue.push(song);
|
||||||
@@ -483,7 +426,7 @@ async function cmdQueue(message) {
|
|||||||
return message.channel.send('📭 Kolejka jest pusta.');
|
return message.channel.send('📭 Kolejka jest pusta.');
|
||||||
}
|
}
|
||||||
const list = state.queue
|
const list = state.queue
|
||||||
.map((s, i) => `${i + 1}. **${s.title}** [${s.duration}]`)
|
.map((s, i) => `${i + 1}. **${s.title}** [${song.duration}]`)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
await message.channel.send(`📜 **Kolejka:**\n${list}`);
|
await message.channel.send(`📜 **Kolejka:**\n${list}`);
|
||||||
}
|
}
|
||||||
@@ -532,16 +475,9 @@ function playNext(guildId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Audio resource with caching ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
function buildAudioResource(url) {
|
function buildAudioResource(url) {
|
||||||
const cachePath = getAudioCachePath(url);
|
const cachePath = getAudioCachePath(url);
|
||||||
|
|
||||||
// Cache HIT — instant play from disk, but only if the file is actually
|
|
||||||
// large enough to be real audio. Because writes are atomic (tmp file +
|
|
||||||
// size-check + rename), a file should only ever exist here if it's
|
|
||||||
// complete and valid — this check is a last-line-of-defense safety net
|
|
||||||
// in case an older/corrupt file is still sitting on disk.
|
|
||||||
if (fs.existsSync(cachePath)) {
|
if (fs.existsSync(cachePath)) {
|
||||||
if (isCacheFileValid(cachePath)) {
|
if (isCacheFileValid(cachePath)) {
|
||||||
console.log(`[Cache] HIT: ${path.basename(cachePath)}`);
|
console.log(`[Cache] HIT: ${path.basename(cachePath)}`);
|
||||||
@@ -553,9 +489,6 @@ function buildAudioResource(url) {
|
|||||||
try { fs.unlinkSync(cachePath); } catch {}
|
try { fs.unlinkSync(cachePath); } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache MISS — join (or start) a shared download for this 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 job = getOrStartDownload(url);
|
||||||
const stream = job.subscribe();
|
const stream = job.subscribe();
|
||||||
|
|
||||||
@@ -566,9 +499,7 @@ function buildAudioResource(url) {
|
|||||||
|
|
||||||
// ─── yt-dlp metadata fetch ────────────────────────────────────────────────────
|
// ─── yt-dlp metadata fetch ────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Dedupe concurrent identical metadata lookups too (e.g. two people paste
|
const pendingMetadata = new Map();
|
||||||
// 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)) {
|
if (pendingMetadata.has(query)) {
|
||||||
@@ -578,12 +509,21 @@ function resolveSong(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}`;
|
||||||
|
|
||||||
const promise = new Promise((resolve, reject) => {
|
const ytdlpArgs = [
|
||||||
const proc = spawn('yt-dlp', [
|
|
||||||
'--no-playlist',
|
'--no-playlist',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (YTDL_COOKIES_FROM_BROWSER) {
|
||||||
|
ytdlpArgs.push('--cookies-from-browser', YTDL_COOKIES_FROM_BROWSER);
|
||||||
|
}
|
||||||
|
|
||||||
|
ytdlpArgs.push(
|
||||||
'--print', '%(title)s|||%(duration_string)s|||%(webpage_url)s',
|
'--print', '%(title)s|||%(duration_string)s|||%(webpage_url)s',
|
||||||
'--', ytQuery,
|
'--', ytQuery
|
||||||
]);
|
);
|
||||||
|
|
||||||
|
const promise = new Promise((resolve, reject) => {
|
||||||
|
const proc = spawn('yt-dlp', ytdlpArgs);
|
||||||
|
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
|
|||||||
Reference in New Issue
Block a user