diff --git a/index.js b/index.js index ba725d9..bca51d1 100644 --- a/index.js +++ b/index.js @@ -1,3 +1,4 @@ +require('dotenv').config(); const { Client, GatewayIntentBits } = require('discord.js'); const { joinVoiceChannel, @@ -15,9 +16,6 @@ const path = require('path'); const crypto = require('crypto'); // ─── 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; if (!TOKEN) { console.error('❌ Missing DISCORD_TOKEN environment variable.'); @@ -26,24 +24,15 @@ if (!TOKEN) { const PREFIX = '!'; 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 ───────────────────────────────────────────────────────────── const CACHE_DIR = path.join(__dirname, 'cache'); 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; -// 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)) { const full = path.join(CACHE_DIR, f); 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'); let metaCache = {}; if (fs.existsSync(META_FILE)) { @@ -67,9 +55,6 @@ if (fs.existsSync(META_FILE)) { } 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'; try { fs.writeFileSync(tmp, JSON.stringify(metaCache, null, 2)); @@ -84,9 +69,6 @@ function getAudioCachePath(url) { 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) { try { return fs.statSync(cachePath).size >= MIN_VALID_CACHE_BYTES; @@ -119,32 +101,7 @@ function getState(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 -// 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 +const activeDownloads = new Map(); class DownloadJob { constructor(url) { @@ -156,13 +113,23 @@ class DownloadJob { this._start(); } - _start() { - const ytdlp = spawn('yt-dlp', [ - '-f', 'bestaudio', - '--no-playlist', - '-o', '-', - '--', this.url, - ], { stdio: ['ignore', 'pipe', 'pipe'] }); +_start() { + const ytdlpArgs = [ + '-f', 'ba/b', // <-- changed from 'bestaudio' + '--no-playlist', + ]; + + if (YTDL_COOKIES_FROM_BROWSER) { + ytdlpArgs.push('--cookies-from-browser', YTDL_COOKIES_FROM_BROWSER); + } + + ytdlpArgs.push( + '-o', '-', + '--', + this.url + ); + + const ytdlp = spawn('yt-dlp', ytdlpArgs, { stdio: ['ignore', 'pipe', 'pipe'] }); const ffmpeg = spawn('ffmpeg', [ '-hide_banner', '-loglevel', 'error', @@ -180,11 +147,6 @@ class DownloadJob { ytdlp.stderr.on('data', (d) => console.error('[yt-dlp]', 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', () => {}); ffmpeg.stdin.on('error', () => {}); ffmpeg.stdout.on('error', () => {}); @@ -205,8 +167,6 @@ class DownloadJob { this.cacheStream = cacheStream; 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) => { if (this.failed) return; if (!cacheStream.destroyed) cacheStream.write(chunk); @@ -243,9 +203,6 @@ class DownloadJob { fs.unlink(this.tmpPath, () => {}); 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) { console.error(`[Cache] Discarding suspiciously small file (${stats.size}B): ${this.url}`); fs.unlink(this.tmpPath, () => {}); @@ -268,7 +225,7 @@ class DownloadJob { 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 + fs.unlink(this.tmpPath, () => {}); for (const sub of this.subscribers) { if (!sub.destroyed) sub.destroy(err); } @@ -276,19 +233,12 @@ class DownloadJob { 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() { const sub = new PassThrough(); this.subscribers.add(sub); const cleanup = () => this.subscribers.delete(sub); sub.on('close', 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); return sub; } @@ -422,9 +372,6 @@ async function cmdPlay(message, query, { force = false } = {}) { 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]) { const song = metaCache[cacheKey]; evictIfInvalid(getAudioCachePath(song.url)); @@ -456,10 +403,6 @@ async function cmdPlay(message, query, { force = false } = {}) { 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); @@ -483,7 +426,7 @@ async function cmdQueue(message) { return message.channel.send('📭 Kolejka jest pusta.'); } const list = state.queue - .map((s, i) => `${i + 1}. **${s.title}** [${s.duration}]`) + .map((s, i) => `${i + 1}. **${s.title}** [${song.duration}]`) .join('\n'); await message.channel.send(`📜 **Kolejka:**\n${list}`); } @@ -532,16 +475,9 @@ function playNext(guildId) { } } -// ─── Audio resource with caching ───────────────────────────────────────────── - function buildAudioResource(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 (isCacheFileValid(cachePath)) { console.log(`[Cache] HIT: ${path.basename(cachePath)}`); @@ -553,9 +489,6 @@ function buildAudioResource(url) { 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 stream = job.subscribe(); @@ -566,9 +499,7 @@ function buildAudioResource(url) { // ─── 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 +const pendingMetadata = new Map(); function resolveSong(query) { if (pendingMetadata.has(query)) { @@ -578,12 +509,21 @@ function resolveSong(query) { const isUrl = query.startsWith('http://') || query.startsWith('https://'); const ytQuery = isUrl ? query : `ytsearch1:${query}`; + const ytdlpArgs = [ + '--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', + '--', ytQuery + ); + const promise = new Promise((resolve, reject) => { - const proc = spawn('yt-dlp', [ - '--no-playlist', - '--print', '%(title)s|||%(duration_string)s|||%(webpage_url)s', - '--', ytQuery, - ]); + const proc = spawn('yt-dlp', ytdlpArgs); let stdout = ''; let stderr = '';