diff --git a/Dockerfile b/Dockerfile index 21cd07d..c4bc476 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,6 @@ WORKDIR /app/web COPY web/package*.json ./ RUN npm install --no-audit --no-fund COPY web/ . -# Umgebungsvariable für React Build verfügbar machen (Vite liest nur VITE_*) ARG VITE_BUILD_CHANNEL=stable ARG VITE_APP_VERSION=1.1.0 ENV VITE_BUILD_CHANNEL=$VITE_BUILD_CHANNEL @@ -21,9 +20,19 @@ COPY server/package*.json ./ RUN npm install --no-audit --no-fund COPY server/ . RUN npm run build -# Nur Prod-Dependencies für Runtime behalten. rm -rf and cleanly install to prevent npm prune bugs RUN rm -rf node_modules && npm install --omit=dev --no-audit --no-fund +# --- Static ffmpeg binary (nur ffmpeg, kein ffprobe - wird nicht benutzt) --- +FROM debian:bookworm-slim AS ffmpeg-fetch +RUN apt-get update && apt-get install -y --no-install-recommends curl xz-utils ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && curl -L https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz \ + -o /tmp/ffmpeg.tar.xz \ + && mkdir -p /tmp/ffmpeg \ + && tar -xJf /tmp/ffmpeg.tar.xz -C /tmp/ffmpeg --strip-components=2 --wildcards "*/bin/ffmpeg" \ + && chmod +x /tmp/ffmpeg/ffmpeg \ + && rm /tmp/ffmpeg.tar.xz + # --- Runtime image --- FROM node:24-slim AS runtime WORKDIR /app @@ -31,11 +40,12 @@ ENV NODE_ENV=production ENV PORT=8080 ENV SOUNDS_DIR=/data/sounds -RUN apt-get update && apt-get install -y ffmpeg curl ca-certificates && rm -rf /var/lib/apt/lists/* \ +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \ && curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp \ && chmod a+rx /usr/local/bin/yt-dlp \ - && yt-dlp --version || true + && apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* +COPY --from=ffmpeg-fetch /tmp/ffmpeg/ffmpeg /usr/local/bin/ffmpeg COPY --from=server-build /app/server/dist ./server/dist COPY --from=server-build /app/server/node_modules ./server/node_modules COPY --from=server-build /app/server/package.json ./server/package.json @@ -44,6 +54,3 @@ COPY --from=web-build /app/web/dist ./web/dist EXPOSE 8080 VOLUME ["/data/sounds"] CMD ["node", "server/dist/index.js"] - - - diff --git a/server/src/index.ts b/server/src/index.ts index 121c097..3c536c4 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -24,7 +24,7 @@ import sodium from 'libsodium-wrappers'; import nacl from 'tweetnacl'; // Streaming externer Plattformen entfernt – nur MP3-URLs werden noch unterstützt import child_process from 'node:child_process'; -import { PassThrough } from 'node:stream'; +import { PassThrough, Readable } from 'node:stream'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -158,6 +158,35 @@ const NORMALIZE_TP = String(process.env.NORMALIZE_TP ?? '-1.5'); const NORM_CACHE_DIR = path.join(SOUNDS_DIR, '.norm-cache'); fs.mkdirSync(NORM_CACHE_DIR, { recursive: true }); +// In-Memory PCM Cache: gecachte PCM-Dateien werden beim ersten Abspielen in den RAM geladen. +// Danach kein Disk-I/O mehr bei Cache-Hits -> nahezu instant playback. +const pcmMemoryCache = new Map(); +const PCM_MEMORY_CACHE_MAX_MB = Number(process.env.PCM_CACHE_MAX_MB ?? '512'); +let pcmMemoryCacheBytes = 0; + +function getPcmFromMemory(cachedPath: string): Buffer | null { + const buf = pcmMemoryCache.get(cachedPath); + if (buf) return buf; + // Erste Anfrage: von Disk in RAM laden + try { + const data = fs.readFileSync(cachedPath); + const newTotal = pcmMemoryCacheBytes + data.byteLength; + if (newTotal <= PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) { + pcmMemoryCache.set(cachedPath, data); + pcmMemoryCacheBytes = newTotal; + } + return data; + } catch { return null; } +} + +function invalidatePcmMemory(cachedPath: string): void { + const buf = pcmMemoryCache.get(cachedPath); + if (buf) { + pcmMemoryCacheBytes -= buf.byteLength; + pcmMemoryCache.delete(cachedPath); + } +} + /** Erzeugt einen Cache-Key aus dem relativen Pfad (Slash-normalisiert, Sonderzeichen escaped) */ function normCacheKey(filePath: string): string { const rel = path.relative(SOUNDS_DIR, filePath).replace(/\\/g, '/'); @@ -172,7 +201,7 @@ function getNormCachePath(filePath: string): string | null { try { const srcMtime = fs.statSync(filePath).mtimeMs; const cacheMtime = fs.statSync(cacheFile).mtimeMs; - if (srcMtime > cacheMtime) { try { fs.unlinkSync(cacheFile); } catch {} return null; } + if (srcMtime > cacheMtime) { try { fs.unlinkSync(cacheFile); } catch {} invalidatePcmMemory(cacheFile); return null; } } catch { return null; } return cacheFile; } @@ -381,9 +410,19 @@ async function playFilePath(guildId: string, channelId: string, filePath: string if (NORMALIZE_ENABLE) { const cachedPath = getNormCachePath(filePath); if (cachedPath) { - // Cache-Hit: gecachte PCM-Datei als Stream lesen (kein ffmpeg, instant) - const pcmStream = fs.createReadStream(cachedPath); - resource = createAudioResource(pcmStream, { inlineVolume: true, inputType: StreamType.Raw }); + // Cache-Hit: PCM aus RAM lesen (kein Disk-I/O, instant) + const pcmBuf = getPcmFromMemory(cachedPath); + if (pcmBuf) { + const useInline = useVolume !== 1; + resource = createAudioResource(Readable.from(pcmBuf), { + inlineVolume: useInline, + inputType: StreamType.Raw + }); + } else { + // Fallback: Stream von Disk + const pcmStream = fs.createReadStream(cachedPath, { highWaterMark: 256 * 1024 }); + resource = createAudioResource(pcmStream, { inlineVolume: true, inputType: StreamType.Raw }); + } } else { // Cache-Miss: ffmpeg streamen (sofortige Wiedergabe) UND gleichzeitig in Cache schreiben const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath)); @@ -402,6 +441,14 @@ async function playFilePath(guildId: string, channelId: string, filePath: string playerStream.end(); cacheWrite.end(); console.log(`${new Date().toISOString()} | Loudnorm cached: ${path.basename(filePath)}`); + // In Memory-Cache laden fuer naechsten Aufruf + try { + const buf = fs.readFileSync(cacheFile); + if (pcmMemoryCacheBytes + buf.byteLength <= PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) { + pcmMemoryCache.set(cacheFile, buf); + pcmMemoryCacheBytes += buf.byteLength; + } + } catch {} }); ff.on('error', () => { try { fs.unlinkSync(cacheFile); } catch {} }); ff.on('close', (code) => { @@ -1535,6 +1582,11 @@ if (fs.existsSync(webDistPath)) { }); } +// Node 24 warnt bei negativen Timeout-Werten (aus @discordjs/voice intern) - harmlos unterdruecken +process.on('warning', (warning) => { + if (warning.name === 'TimeoutNegativeWarning') return; + console.warn(warning.name + ': ' + warning.message); +}); process.on('uncaughtException', (err) => { console.error(`FATAL uncaughtException:`, err); process.exit(1);