From 56e67eb151143a5313ce8822f3af38ea37f88ef2 Mon Sep 17 00:00:00 2001 From: kq Date: Sat, 8 Aug 2026 16:44:05 +0000 Subject: [PATCH] Update index.js --- index.js | 224 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 163 insertions(+), 61 deletions(-) diff --git a/index.js b/index.js index 21b506d..1ffec6e 100644 --- a/index.js +++ b/index.js @@ -15,7 +15,14 @@ const path = require('path'); const crypto = require('crypto'); // ─── 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 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'); 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 } -// Saved to disk so it survives bot restarts const META_FILE = path.join(CACHE_DIR, 'metadata.json'); let metaCache = {}; if (fs.existsSync(META_FILE)) { @@ -57,6 +72,130 @@ function getState(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 ─────────────────────────────────────────────────────────── const client = new Client({ intents: [ @@ -121,7 +260,6 @@ async function cmdPlay(message, query) { 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) { @@ -149,7 +287,6 @@ async function cmdPlay(message, query) { } } - // Create player if needed if (!state.player) { state.player = createAudioPlayer(); 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(); if (metaCache[cacheKey]) { const song = metaCache[cacheKey]; @@ -181,11 +317,9 @@ async function cmdPlay(message, query) { 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(); @@ -266,7 +400,8 @@ function playNext(guildId) { function buildAudioResource(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)) { console.log(`[Cache] HIT: ${path.basename(cachePath)}`); return createAudioResource(fs.createReadStream(cachePath), { @@ -274,70 +409,32 @@ function buildAudioResource(url) { }); } - // Audio cache MISS — stream + save to disk simultaneously - console.log(`[Cache] MISS: downloading ${url}`); + // 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 stream = job.subscribe(); - 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, { + return createAudioResource(stream, { inputType: StreamType.OggOpus, }); - - resource.playStream.on('close', () => { - if (!ytdlp.killed) ytdlp.kill(); - if (!ffmpeg.killed) ffmpeg.kill(); - }); - - return resource; } // ─── 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) { + if (pendingMetadata.has(query)) { + return pendingMetadata.get(query); + } + const isUrl = query.startsWith('http://') || query.startsWith('https://'); const ytQuery = isUrl ? query : `ytsearch1:${query}`; - return new Promise((resolve, reject) => { + const promise = new Promise((resolve, reject) => { const proc = spawn('yt-dlp', [ '--no-playlist', '--print', '%(title)s|||%(duration_string)s|||%(webpage_url)s', @@ -364,7 +461,12 @@ function resolveSong(query) { resolve({ title: query, duration: '?:??', url: query }); } }); + }).finally(() => { + pendingMetadata.delete(query); }); + + pendingMetadata.set(query, promise); + return promise; } // ─── Start ────────────────────────────────────────────────────────────────────