gaming-hub/server/src/plugins/soundboard/index.ts
Daniel b3080fb763 Refactor: Zentralisiertes Admin-Login für alle Tabs
Admin-Auth aus Soundboard-Plugin in core/auth.ts extrahiert.
Ein Login-Button im Header gilt jetzt für die gesamte Webseite.
Cookie-basiert (HMAC-SHA256, 7 Tage) — überlebt Page-Reload.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:11:34 +01:00

1244 lines
61 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type express from 'express';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import child_process from 'node:child_process';
import { PassThrough, Readable } from 'node:stream';
import multer from 'multer';
import {
joinVoiceChannel, createAudioPlayer, createAudioResource,
AudioPlayerStatus, NoSubscriberBehavior, getVoiceConnection,
VoiceConnectionStatus, StreamType, entersState,
generateDependencyReport,
type VoiceConnection, type AudioResource,
} from '@discordjs/voice';
import sodium from 'libsodium-wrappers';
import nacl from 'tweetnacl';
import { ChannelType, Events, type VoiceBasedChannel, type VoiceState, type Message } from 'discord.js';
import type { Plugin, PluginContext } from '../../core/plugin.js';
import { sseBroadcast } from '../../core/sse.js';
import { requireAdmin as requireAdminFactory } from '../../core/auth.js';
// ── Config (env) ──
const SOUNDS_DIR = process.env.SOUNDS_DIR ?? '/data/sounds';
const NORMALIZE_ENABLE = String(process.env.NORMALIZE_ENABLE ?? 'true').toLowerCase() !== 'false';
const NORMALIZE_I = String(process.env.NORMALIZE_I ?? '-16');
const NORMALIZE_LRA = String(process.env.NORMALIZE_LRA ?? '11');
const NORMALIZE_TP = String(process.env.NORMALIZE_TP ?? '-1.5');
const NORM_CONCURRENCY = Math.max(1, Number(process.env.NORM_CONCURRENCY ?? 2));
const PCM_MEMORY_CACHE_MAX_MB = Number(process.env.PCM_CACHE_MAX_MB ?? '512');
const PCM_PER_FILE_MAX_MB = 50;
// ── Types ──
type Category = { id: string; name: string; color?: string; sort?: number };
type PersistedState = {
volumes: Record<string, number>;
plays: Record<string, number>;
totalPlays: number;
categories?: Category[];
fileCategories?: Record<string, string[]>;
fileBadges?: Record<string, string[]>;
selectedChannels?: Record<string, string>;
entranceSounds?: Record<string, string>;
exitSounds?: Record<string, string>;
};
type ListedSound = { fileName: string; name: string; folder: string; relativePath: string };
type GuildAudioState = {
connection: VoiceConnection;
player: ReturnType<typeof createAudioPlayer>;
guildId: string;
channelId: string;
currentResource?: AudioResource;
currentVolume: number;
};
// ── Persisted State ──
const STATE_FILE = path.join(SOUNDS_DIR, 'state.json');
const STATE_FILE_OLD = path.join(path.resolve(SOUNDS_DIR, '..'), 'state.json');
function readPersistedState(): PersistedState {
try {
if (fs.existsSync(STATE_FILE)) {
const p = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
return { volumes: p.volumes ?? {}, plays: p.plays ?? {}, totalPlays: p.totalPlays ?? 0,
categories: Array.isArray(p.categories) ? p.categories : [], fileCategories: p.fileCategories ?? {},
fileBadges: p.fileBadges ?? {}, selectedChannels: p.selectedChannels ?? {},
entranceSounds: p.entranceSounds ?? {}, exitSounds: p.exitSounds ?? {} };
}
if (fs.existsSync(STATE_FILE_OLD)) {
const p = JSON.parse(fs.readFileSync(STATE_FILE_OLD, 'utf8'));
const m: PersistedState = { volumes: p.volumes ?? {}, plays: p.plays ?? {}, totalPlays: p.totalPlays ?? 0,
categories: Array.isArray(p.categories) ? p.categories : [], fileCategories: p.fileCategories ?? {},
fileBadges: p.fileBadges ?? {}, selectedChannels: p.selectedChannels ?? {},
entranceSounds: p.entranceSounds ?? {}, exitSounds: p.exitSounds ?? {} };
try { fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true }); fs.writeFileSync(STATE_FILE, JSON.stringify(m, null, 2), 'utf8'); } catch {}
return m;
}
} catch {}
return { volumes: {}, plays: {}, totalPlays: 0 };
}
let persistedState: PersistedState;
let _writeTimer: ReturnType<typeof setTimeout> | null = null;
function writeState(): void {
try { fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true }); fs.writeFileSync(STATE_FILE, JSON.stringify(persistedState, null, 2), 'utf8'); } catch (e) { console.warn('[Soundboard] state write error:', e); }
}
function writeStateDebounced(): void {
if (_writeTimer) return;
_writeTimer = setTimeout(() => { _writeTimer = null; writeState(); }, 2000);
}
function getPersistedVolume(guildId: string): number {
const v = persistedState.volumes[guildId];
return typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 1;
}
function safeSoundsPath(rel: string): string | null {
const resolved = path.resolve(SOUNDS_DIR, rel);
if (!resolved.startsWith(path.resolve(SOUNDS_DIR) + path.sep) && resolved !== path.resolve(SOUNDS_DIR)) return null;
return resolved;
}
function incrementPlaysFor(relativePath: string) {
try { const key = relativePath.replace(/\\/g, '/');
persistedState.plays[key] = (persistedState.plays[key] ?? 0) + 1;
persistedState.totalPlays = (persistedState.totalPlays ?? 0) + 1;
writeStateDebounced();
} catch {}
}
// ── Loudnorm Cache ──
const NORM_CACHE_DIR = path.join(SOUNDS_DIR, '.norm-cache');
function normCacheKey(filePath: string): string {
const rel = path.relative(SOUNDS_DIR, filePath).replace(/\\/g, '/');
return rel.replace(/[/\\:*?"<>|]/g, '_') + '.pcm';
}
function getNormCachePath(filePath: string): string | null {
const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath));
if (!fs.existsSync(cacheFile)) return null;
try {
const srcMtime = fs.statSync(filePath).mtimeMs;
const cacheMtime = fs.statSync(cacheFile).mtimeMs;
if (srcMtime > cacheMtime) { try { fs.unlinkSync(cacheFile); } catch {} invalidatePcmMemory(cacheFile); return null; }
} catch { return null; }
return cacheFile;
}
function normalizeToCache(filePath: string): Promise<string> {
const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath));
return new Promise((resolve, reject) => {
const ff = child_process.spawn('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', '-i', filePath,
'-af', `loudnorm=I=${NORMALIZE_I}:LRA=${NORMALIZE_LRA}:TP=${NORMALIZE_TP}`,
'-f', 's16le', '-ar', '48000', '-ac', '2', cacheFile]);
ff.on('error', reject);
ff.on('close', (code) => { if (code === 0) resolve(cacheFile); else reject(new Error(`ffmpeg exit ${code}`)); });
});
}
// ── yt-dlp URL detection & download ──
const YTDLP_HOSTS = [
'youtube.com', 'www.youtube.com', 'm.youtube.com', 'youtu.be',
'music.youtube.com',
'instagram.com', 'www.instagram.com',
];
function isYtDlpUrl(url: string): boolean {
try {
const host = new URL(url).hostname.toLowerCase();
return YTDLP_HOSTS.some(h => host === h || host.endsWith('.' + h));
} catch { return false; }
}
function isDirectMp3Url(url: string): boolean {
try {
return new URL(url).pathname.toLowerCase().endsWith('.mp3');
} catch { return false; }
}
function isSupportedUrl(url: string): boolean {
return isYtDlpUrl(url) || isDirectMp3Url(url);
}
/** Download audio via yt-dlp → MP3 file in SOUNDS_DIR */
function downloadWithYtDlp(url: string): Promise<{ filename: string; filepath: string }> {
return new Promise((resolve, reject) => {
const outputTemplate = path.join(SOUNDS_DIR, '%(title)s.%(ext)s');
const args = [
'-x', // extract audio only
'--audio-format', 'mp3', // convert to MP3
'--audio-quality', '0', // best quality
'-o', outputTemplate, // output path template
'--no-playlist', // single video only
'--no-overwrites', // don't overwrite existing
'--restrict-filenames', // safe filenames (ASCII, no spaces)
'--max-filesize', '50m', // same limit as file upload
'--socket-timeout', '30', // timeout for slow connections
'--verbose', // verbose output for logging
url,
];
const startTime = Date.now();
console.log(`${SB} [yt-dlp] ▶ START url=${url}`);
console.log(`${SB} [yt-dlp] args: yt-dlp ${args.join(' ')}`);
const proc = child_process.spawn('yt-dlp', args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (d: Buffer) => {
const line = d.toString();
stdout += line;
// Stream yt-dlp progress to console in real-time
for (const l of line.split('\n').filter((s: string) => s.trim())) {
console.log(`${SB} [yt-dlp:out] ${l.trim()}`);
}
});
proc.stderr?.on('data', (d: Buffer) => {
const line = d.toString();
stderr += line;
for (const l of line.split('\n').filter((s: string) => s.trim())) {
console.error(`${SB} [yt-dlp:err] ${l.trim()}`);
}
});
proc.on('error', (err) => {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.error(`${SB} [yt-dlp] ✗ SPAWN ERROR after ${elapsed}s: ${err.message}`);
reject(new Error('yt-dlp nicht verfügbar'));
});
proc.on('close', (code) => {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
if (code !== 0) {
console.error(`${SB} [yt-dlp] ✗ FAILED exit=${code} after ${elapsed}s`);
console.error(`${SB} [yt-dlp] stderr (last 1000 chars): ${stderr.slice(-1000)}`);
console.error(`${SB} [yt-dlp] stdout (last 500 chars): ${stdout.slice(-500)}`);
// Extract useful error for frontend
if (stderr.includes('Video unavailable') || stderr.includes('is not available'))
reject(new Error('Video nicht verfügbar'));
else if (stderr.includes('Private video'))
reject(new Error('Privates Video'));
else if (stderr.includes('Sign in') || stderr.includes('login'))
reject(new Error('Login erforderlich'));
else if (stderr.includes('exceeds maximum'))
reject(new Error('Datei zu groß (max 50 MB)'));
else if (stderr.includes('Unsupported URL'))
reject(new Error('URL nicht unterstützt'));
else if (stderr.includes('HTTP Error 404'))
reject(new Error('Video nicht gefunden (404)'));
else if (stderr.includes('HTTP Error 403'))
reject(new Error('Zugriff verweigert (403)'));
else
reject(new Error(`yt-dlp Fehler (exit ${code})`));
return;
}
console.log(`${SB} [yt-dlp] ✓ DONE exit=0 after ${elapsed}s`);
// Find the downloaded MP3 file — yt-dlp prints the final filename
const destMatch = stdout.match(/\[ExtractAudio\] Destination: (.+\.mp3)/i)
?? stdout.match(/\[download\] (.+\.mp3) has already been downloaded/i)
?? stdout.match(/Destination: (.+\.mp3)/i);
if (destMatch) {
const filepath = destMatch[1].trim();
const filename = path.basename(filepath);
console.log(`${SB} [yt-dlp] saved: ${filename} (regex match)`);
resolve({ filename, filepath });
return;
}
// Fallback: scan SOUNDS_DIR for newest MP3 (within last 60s)
const now = Date.now();
const mp3s = fs.readdirSync(SOUNDS_DIR)
.filter(f => f.endsWith('.mp3'))
.map(f => ({ name: f, mtime: fs.statSync(path.join(SOUNDS_DIR, f)).mtimeMs }))
.filter(f => now - f.mtime < 60000)
.sort((a, b) => b.mtime - a.mtime);
if (mp3s.length > 0) {
const filename = mp3s[0].name;
console.log(`${SB} [yt-dlp] saved: ${filename} (fallback scan, ${mp3s.length} recent files)`);
resolve({ filename, filepath: path.join(SOUNDS_DIR, filename) });
return;
}
console.error(`${SB} [yt-dlp] ✗ OUTPUT FILE NOT FOUND`);
console.error(`${SB} [yt-dlp] full stdout:\n${stdout}`);
console.error(`${SB} [yt-dlp] full stderr:\n${stderr}`);
reject(new Error('Download abgeschlossen, aber Datei nicht gefunden'));
});
});
}
// ── PCM Memory Cache ──
const pcmMemoryCache = new Map<string, Buffer>();
let pcmMemoryCacheBytes = 0;
function getPcmFromMemory(cachedPath: string): Buffer | null {
const buf = pcmMemoryCache.get(cachedPath);
if (buf) return buf;
try {
const stat = fs.statSync(cachedPath);
if (stat.size > PCM_PER_FILE_MAX_MB * 1024 * 1024) return null;
if (pcmMemoryCacheBytes + stat.size > PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) return null;
const data = fs.readFileSync(cachedPath);
pcmMemoryCache.set(cachedPath, data);
pcmMemoryCacheBytes += data.byteLength;
return data;
} catch { return null; }
}
function invalidatePcmMemory(cachedPath: string): void {
const buf = pcmMemoryCache.get(cachedPath);
if (buf) { pcmMemoryCacheBytes -= buf.byteLength; pcmMemoryCache.delete(cachedPath); }
}
// ── Sound listing ──
function listAllSounds(): ListedSound[] {
const rootEntries = fs.readdirSync(SOUNDS_DIR, { withFileTypes: true });
const rootFiles: ListedSound[] = rootEntries
.filter(d => d.isFile() && /\.(mp3|wav)$/i.test(d.name))
.map(d => ({ fileName: d.name, name: path.parse(d.name).name, folder: '', relativePath: d.name }));
const folderItems: ListedSound[] = [];
for (const dirent of rootEntries.filter(d => d.isDirectory() && d.name !== '.norm-cache')) {
const folderPath = path.join(SOUNDS_DIR, dirent.name);
for (const e of fs.readdirSync(folderPath, { withFileTypes: true })) {
if (!e.isFile() || !/\.(mp3|wav)$/i.test(e.name)) continue;
folderItems.push({ fileName: e.name, name: path.parse(e.name).name, folder: dirent.name,
relativePath: path.join(dirent.name, e.name) });
}
}
return [...rootFiles, ...folderItems].sort((a, b) => a.name.localeCompare(b.name));
}
// ── Norm cache sync ──
async function syncNormCache(): Promise<void> {
if (!NORMALIZE_ENABLE) return;
const t0 = Date.now();
const allSounds = listAllSounds();
const expectedKeys = new Set<string>();
const toProcess: string[] = [];
for (const s of allSounds) {
const fp = path.join(SOUNDS_DIR, s.relativePath);
const key = normCacheKey(fp);
expectedKeys.add(key);
if (!fs.existsSync(fp)) continue;
if (getNormCachePath(fp)) continue;
toProcess.push(fp);
}
let created = 0, failed = 0;
const skipped = allSounds.length - toProcess.length;
const queue = [...toProcess];
async function worker(): Promise<void> {
while (queue.length > 0) {
const fp = queue.shift()!;
try { await normalizeToCache(fp); created++; }
catch (e) { failed++; console.warn(`[Soundboard] norm-cache failed: ${path.relative(SOUNDS_DIR, fp)}`, e); }
}
}
await Promise.all(Array.from({ length: Math.min(NORM_CONCURRENCY, toProcess.length || 1) }, worker));
let cleaned = 0;
try {
for (const f of fs.readdirSync(NORM_CACHE_DIR)) {
if (!expectedKeys.has(f)) { try { fs.unlinkSync(path.join(NORM_CACHE_DIR, f)); cleaned++; } catch {} }
}
} catch {}
console.log(`[Soundboard] Norm-cache sync (${((Date.now() - t0) / 1000).toFixed(1)}s): ${created} new, ${skipped} cached, ${failed} failed, ${cleaned} orphans`);
}
// ── Audio State ──
const guildAudioState = new Map<string, GuildAudioState>();
const partyTimers = new Map<string, NodeJS.Timeout>();
const partyActive = new Set<string>();
const nowPlaying = new Map<string, string>();
const connectedSince = new Map<string, string>();
// ── Logging helper ──
const SB = '[Soundboard]';
// ── Voice Lifecycle ──
async function ensureConnectionReady(connection: VoiceConnection, channelId: string, guildId: string, guild: any): Promise<VoiceConnection> {
console.log(`${SB} ensureConnectionReady: guild=${guildId} channel=${channelId} status=${connection.state.status}`);
try { await entersState(connection, VoiceConnectionStatus.Ready, 15_000); console.log(`${SB} Connection ready (attempt 1)`); return connection; }
catch (e) { console.warn(`${SB} Attempt 1 failed: ${(e as Error)?.message ?? e}`); }
try { connection.rejoin({ channelId, selfDeaf: false, selfMute: false }); await entersState(connection, VoiceConnectionStatus.Ready, 15_000); console.log(`${SB} Connection ready (rejoin)`); return connection; }
catch (e) { console.warn(`${SB} Rejoin failed: ${(e as Error)?.message ?? e}`); }
try { connection.destroy(); } catch {}
guildAudioState.delete(guildId);
console.log(`${SB} Creating fresh connection (attempt 3)...`);
const newConn = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
newConn.on('stateChange', (o: any, n: any) => console.log(`${SB} [fresh-conn] ${o.status}${n.status}`));
newConn.on('error', (err: any) => console.error(`${SB} [fresh-conn] ERROR: ${err?.message ?? err}`));
try { await entersState(newConn, VoiceConnectionStatus.Ready, 15_000); console.log(`${SB} Connection ready (fresh)`); return newConn; }
catch (e) { console.error(`${SB} All 3 connection attempts failed: ${(e as Error)?.message ?? e}`); try { newConn.destroy(); } catch {} guildAudioState.delete(guildId); throw new Error('Voice connection failed after 3 attempts'); }
}
function attachVoiceLifecycle(state: GuildAudioState, guild: any) {
const { connection } = state;
if ((connection as any).__lifecycleAttached) return;
try { (connection as any).setMaxListeners?.(0); } catch {}
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 3;
let isReconnecting = false;
connection.on('stateChange', async (oldS: any, newS: any) => {
console.log(`${SB} Voice state: ${oldS.status}${newS.status} (guild=${state.guildId})`);
if (newS.status === VoiceConnectionStatus.Ready) {
reconnectAttempts = 0; isReconnecting = false;
if (!connectedSince.has(state.guildId)) connectedSince.set(state.guildId, new Date().toISOString());
console.log(`${SB} Voice READY for guild=${state.guildId}`);
return;
}
if (isReconnecting) { console.log(`${SB} Already reconnecting, skipping ${newS.status}`); return; }
try {
if (newS.status === VoiceConnectionStatus.Disconnected) {
console.warn(`${SB} Disconnected waiting for Signalling/Connecting...`);
try { await Promise.race([entersState(connection, VoiceConnectionStatus.Signalling, 5_000), entersState(connection, VoiceConnectionStatus.Connecting, 5_000)]); }
catch {
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { reconnectAttempts++; console.log(`${SB} Rejoin attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`); connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); }
else { reconnectAttempts = 0; console.log(`${SB} Max reconnect attempts reached, creating fresh connection`); try { connection.destroy(); } catch {}
const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild); }
}
} else if (newS.status === VoiceConnectionStatus.Destroyed) {
console.warn(`${SB} Connection destroyed, recreating...`);
connectedSince.delete(state.guildId);
const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild);
} else if (newS.status === VoiceConnectionStatus.Connecting || newS.status === VoiceConnectionStatus.Signalling) {
isReconnecting = true;
console.log(`${SB} Waiting for Ready from ${newS.status}...`);
try { await entersState(connection, VoiceConnectionStatus.Ready, 15_000); }
catch (e) {
reconnectAttempts++;
console.warn(`${SB} Timeout waiting for Ready from ${newS.status} (attempt ${reconnectAttempts}): ${(e as Error)?.message ?? e}`);
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { await new Promise(r => setTimeout(r, reconnectAttempts * 2000)); isReconnecting = false; connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); }
else { reconnectAttempts = 0; isReconnecting = false; console.error(`${SB} Max attempts from ${newS.status}, fresh connection`); try { connection.destroy(); } catch {}
const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild); }
}
}
} catch (e) { console.error(`${SB} Lifecycle error: ${(e as Error)?.message ?? e}`); isReconnecting = false; }
});
(connection as any).__lifecycleAttached = true;
}
// ── Debug adapter wrapper ──
// voiceAdapterCreator(libraryMethods) → { sendPayload, destroy }
// libraryMethods = { onVoiceServerUpdate, onVoiceStateUpdate, destroy }
// returned adapter = { sendPayload(payload) → boolean, destroy() }
function debugAdapterCreator(guild: any): any {
const original = guild.voiceAdapterCreator;
return (libraryMethods: any) => {
// Wrap library methods to log when Discord gateway events arrive
const wrappedLibraryMethods = {
...libraryMethods,
onVoiceServerUpdate(data: any) {
console.log(`${SB} ← onVoiceServerUpdate: token=${data?.token ? 'yes' : 'no'} endpoint=${data?.endpoint ?? 'none'}`);
return libraryMethods.onVoiceServerUpdate(data);
},
onVoiceStateUpdate(data: any) {
console.log(`${SB} ← onVoiceStateUpdate: session_id=${data?.session_id ? 'yes' : 'no'} channel_id=${data?.channel_id ?? 'none'}`);
return libraryMethods.onVoiceStateUpdate(data);
},
};
// Call original adapter creator with our wrapped library methods
const adapter = original(wrappedLibraryMethods);
// Wrap the adapter's sendPayload to log outgoing gateway commands
const origSend = adapter.sendPayload.bind(adapter);
adapter.sendPayload = (payload: any) => {
const result = origSend(payload);
console.log(`${SB} → sendPayload op=${payload?.op ?? '?'} guild=${payload?.d?.guild_id ?? '?'} channel=${payload?.d?.channel_id ?? '?'}${result}`);
return result;
};
return adapter;
};
}
// ── Playback ──
let _pluginCtx: PluginContext | null = null;
async function playFilePath(guildId: string, channelId: string, filePath: string, volume?: number, relativeKey?: string): Promise<void> {
console.log(`${SB} playFilePath: guild=${guildId} channel=${channelId} file=${path.basename(filePath)} vol=${volume ?? 'default'}`);
const ctx = _pluginCtx!;
const guild = ctx.client.guilds.cache.get(guildId);
if (!guild) { console.error(`${SB} Guild ${guildId} not found in cache (cached: ${ctx.client.guilds.cache.map(g => g.id).join(', ')})`); throw new Error('Guild nicht gefunden'); }
let state = guildAudioState.get(guildId);
if (!state) {
console.log(`${SB} No existing audio state, creating new connection...`);
const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
// Debug: catch ALL state transitions and errors from the start
connection.on('stateChange', (o: any, n: any) => {
console.log(`${SB} [conn] ${o.status}${n.status}`);
// Log networking info if available
if (n.networking) console.log(`${SB} [conn] networking state: ${n.networking?.state?.code ?? 'unknown'}`);
});
connection.on('error', (err: any) => console.error(`${SB} [conn] ERROR: ${err?.message ?? err}`));
console.log(`${SB} Connection created, initial status=${connection.state.status}, state keys=${Object.keys(connection.state).join(',')}`);
const player = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } });
connection.subscribe(player);
state = { connection, player, guildId, channelId, currentVolume: getPersistedVolume(guildId) };
guildAudioState.set(guildId, state);
state.connection = await ensureConnectionReady(connection, channelId, guildId, guild);
attachVoiceLifecycle(state, guild);
console.log(`${SB} New voice connection established`);
} else {
console.log(`${SB} Existing audio state found, connection status=${state.connection.state.status}`);
}
// Channel-Wechsel
try {
const current = getVoiceConnection(guildId, 'soundboard');
if (current && current.joinConfig?.channelId !== channelId) {
current.destroy();
const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
const player = state.player ?? createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } });
connection.subscribe(player);
state = { connection, player, guildId, channelId, currentVolume: state.currentVolume ?? getPersistedVolume(guildId) };
guildAudioState.set(guildId, state);
state.connection = await ensureConnectionReady(connection, channelId, guildId, guild);
attachVoiceLifecycle(state, guild);
}
} catch {}
if (!getVoiceConnection(guildId, 'soundboard')) {
const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
const player = state?.player ?? createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } });
connection.subscribe(player);
state = { connection, player, guildId, channelId, currentVolume: state?.currentVolume ?? getPersistedVolume(guildId) };
guildAudioState.set(guildId, state);
state.connection = await ensureConnectionReady(connection, channelId, guildId, guild);
attachVoiceLifecycle(state, guild);
}
const useVolume = typeof volume === 'number' && Number.isFinite(volume)
? Math.max(0, Math.min(1, volume))
: (state.currentVolume ?? 1);
let resource: AudioResource;
if (NORMALIZE_ENABLE) {
const cachedPath = getNormCachePath(filePath);
if (cachedPath) {
const pcmBuf = getPcmFromMemory(cachedPath);
if (pcmBuf) {
resource = createAudioResource(Readable.from(pcmBuf), { inlineVolume: useVolume !== 1, inputType: StreamType.Raw });
} else {
resource = createAudioResource(fs.createReadStream(cachedPath, { highWaterMark: 256 * 1024 }), { inlineVolume: true, inputType: StreamType.Raw });
}
} else {
const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath));
const ff = child_process.spawn('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-i', filePath,
'-af', `loudnorm=I=${NORMALIZE_I}:LRA=${NORMALIZE_LRA}:TP=${NORMALIZE_TP}`,
'-f', 's16le', '-ar', '48000', '-ac', '2', 'pipe:1']);
const playerStream = new PassThrough();
const cacheWrite = fs.createWriteStream(cacheFile);
ff.stdout.on('data', (chunk: Buffer) => { playerStream.write(chunk); cacheWrite.write(chunk); });
ff.stdout.on('end', () => {
playerStream.end(); cacheWrite.end();
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) => { if (code !== 0) { try { fs.unlinkSync(cacheFile); } catch {} } });
resource = createAudioResource(playerStream, { inlineVolume: true, inputType: StreamType.Raw });
}
} else {
resource = createAudioResource(filePath, { inlineVolume: true });
}
if (resource.volume) resource.volume.setVolume(useVolume);
state.player.stop();
console.log(`${SB} Playing resource: vol=${useVolume} normalized=${NORMALIZE_ENABLE} connStatus=${state.connection.state.status}`);
// Log player errors
state.player.removeAllListeners('error');
state.player.on('error', (err: any) => {
console.error(`${SB} AudioPlayer error: ${err?.message ?? err}`);
if (err?.resource?.metadata) console.error(`${SB} resource metadata:`, err.resource.metadata);
});
state.player.on('stateChange', (oldS: any, newS: any) => {
if (oldS.status !== newS.status) console.log(`${SB} Player state: ${oldS.status}${newS.status}`);
});
state.player.play(resource);
state.currentResource = resource;
state.currentVolume = useVolume;
const soundLabel = relativeKey ? path.parse(relativeKey).name : path.parse(filePath).name;
nowPlaying.set(guildId, soundLabel);
console.log(`${SB} Now playing: "${soundLabel}" in guild=${guildId}`);
sseBroadcast({ type: 'soundboard_nowplaying', plugin: 'soundboard', guildId, name: soundLabel });
if (relativeKey) incrementPlaysFor(relativeKey);
}
// ── Party Mode ──
function schedulePartyPlayback(guildId: string, channelId: string) {
const doPlay = async () => {
try {
const all = listAllSounds();
if (all.length === 0) return;
const pick = all[Math.floor(Math.random() * all.length)];
await playFilePath(guildId, channelId, path.join(SOUNDS_DIR, pick.relativePath));
} catch (e) { console.error('[Soundboard] party play error:', e); }
};
const loop = async () => {
if (!partyActive.has(guildId)) return;
await doPlay();
if (!partyActive.has(guildId)) return;
const delay = 30_000 + Math.floor(Math.random() * 60_000);
partyTimers.set(guildId, setTimeout(loop, delay));
};
partyActive.add(guildId);
void loop();
sseBroadcast({ type: 'soundboard_party', plugin: 'soundboard', guildId, active: true, channelId });
}
// ── Discord Commands (DM) ──
async function handleCommand(message: Message, content: string) {
const reply = async (txt: string) => { try { await message.author.send?.(txt); } catch { await message.reply(txt); } };
const parts = content.split(/\s+/);
const cmd = parts[0].toLowerCase();
if (cmd === '?help') {
await reply('Soundboard Commands:\n?help - Hilfe\n?list - Sounds\n?entrance <file> | remove\n?exit <file> | remove');
return;
}
if (cmd === '?list') {
const files = listAllSounds().map(s => s.relativePath);
await reply(files.length ? files.join('\n') : 'Keine Dateien.');
return;
}
if (cmd === '?entrance' || cmd === '?exit') {
const isEntrance = cmd === '?entrance';
const map = isEntrance ? 'entranceSounds' : 'exitSounds';
const [, fileNameRaw] = parts;
const userId = message.author?.id ?? '';
if (!userId) { await reply('Kein Benutzer erkannt.'); return; }
const fileName = fileNameRaw?.trim();
if (!fileName) { await reply(`Verwendung: ${cmd} <datei.mp3|datei.wav> | remove`); return; }
if (/^(remove|clear|delete)$/i.test(fileName)) {
persistedState[map] = persistedState[map] ?? {};
delete (persistedState[map] as Record<string, string>)[userId];
writeState();
await reply(`${isEntrance ? 'Entrance' : 'Exit'}-Sound entfernt.`);
return;
}
if (!/\.(mp3|wav)$/i.test(fileName)) { await reply('Nur .mp3 oder .wav'); return; }
const resolve = (() => {
try {
if (fs.existsSync(path.join(SOUNDS_DIR, fileName))) return fileName;
for (const d of fs.readdirSync(SOUNDS_DIR, { withFileTypes: true })) {
if (!d.isDirectory()) continue;
if (fs.existsSync(path.join(SOUNDS_DIR, d.name, fileName))) return `${d.name}/${fileName}`;
}
return '';
} catch { return ''; }
})();
if (!resolve) { await reply('Datei nicht gefunden. Nutze ?list.'); return; }
persistedState[map] = persistedState[map] ?? {};
(persistedState[map] as Record<string, string>)[userId] = resolve;
writeState();
await reply(`${isEntrance ? 'Entrance' : 'Exit'}-Sound gesetzt: ${resolve}`);
return;
}
await reply('Unbekannter Command. Nutze ?help.');
}
// ── The Plugin ──
const soundboardPlugin: Plugin = {
name: 'soundboard',
version: '1.0.0',
description: 'Discord Soundboard MP3/WAV Sounds im Voice-Channel abspielen',
async init(ctx) {
_pluginCtx = ctx;
fs.mkdirSync(SOUNDS_DIR, { recursive: true });
fs.mkdirSync(NORM_CACHE_DIR, { recursive: true });
persistedState = readPersistedState();
// Voice encryption libs must be initialized before first voice connection
await sodium.ready;
void nacl.randomBytes(1);
console.log(generateDependencyReport());
console.log(`[Soundboard] ${listAllSounds().length} sounds, ${persistedState.totalPlays ?? 0} total plays`);
},
async onReady(ctx) {
// Entrance/Exit Sounds
ctx.client.on(Events.VoiceStateUpdate, async (oldState: VoiceState, newState: VoiceState) => {
try {
const userId = (newState.id || oldState.id) as string;
if (!userId || userId === ctx.client.user?.id) return;
const guildId = (newState.guild?.id || oldState.guild?.id) as string;
if (!guildId) return;
const before = oldState.channelId;
const after = newState.channelId;
if (after && before !== after) {
const file = persistedState.entranceSounds?.[userId];
if (file) {
const abs = path.join(SOUNDS_DIR, file.replace(/\\/g, '/'));
if (fs.existsSync(abs)) { try { await playFilePath(guildId, after, abs, undefined, file); } catch {} }
}
}
if (before && !after) {
const file = persistedState.exitSounds?.[userId];
if (file) {
const abs = path.join(SOUNDS_DIR, file.replace(/\\/g, '/'));
if (fs.existsSync(abs)) { try { await playFilePath(guildId, before, abs, undefined, file); } catch {} }
}
}
} catch {}
});
// DM Commands
ctx.client.on(Events.MessageCreate, async (message: Message) => {
try {
if (message.author?.bot) return;
const content = (message.content || '').trim();
if (content.startsWith('?')) { await handleCommand(message, content); return; }
if (!message.channel?.isDMBased?.()) return;
if (message.attachments.size === 0) return;
for (const [, attachment] of message.attachments) {
const name = attachment.name ?? 'upload';
if (!/\.(mp3|wav)$/i.test(name)) continue;
const safeName = name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
let targetPath = path.join(SOUNDS_DIR, safeName);
let i = 2;
while (fs.existsSync(targetPath)) { const { name: n, ext } = path.parse(safeName); targetPath = path.join(SOUNDS_DIR, `${n}-${i}${ext}`); i++; }
const res = await fetch(attachment.url);
if (!res.ok) continue;
fs.writeFileSync(targetPath, Buffer.from(await res.arrayBuffer()));
if (NORMALIZE_ENABLE) normalizeToCache(targetPath).catch(() => {});
await message.author.send?.(`Sound gespeichert: ${path.basename(targetPath)}`);
}
} catch {}
});
// Norm-Cache Sync
syncNormCache();
// Voice stats broadcast
setInterval(() => {
if (guildAudioState.size === 0) return;
for (const [gId, st] of guildAudioState) {
const status = st.connection.state?.status ?? 'unknown';
if (status === 'ready' && !connectedSince.has(gId)) connectedSince.set(gId, new Date().toISOString());
const ch = ctx.client.channels.cache.get(st.channelId);
sseBroadcast({ type: 'soundboard_voicestats', plugin: 'soundboard', guildId: gId,
voicePing: (st.connection.ping as any)?.ws ?? null, gatewayPing: ctx.client.ws.ping,
status, channelName: ch && 'name' in ch ? (ch as any).name : null,
connectedSince: connectedSince.get(gId) ?? null });
}
}, 5_000);
},
registerRoutes(app: express.Application, ctx: PluginContext) {
const requireAdmin = requireAdminFactory(ctx.adminPwd);
// ── Sounds ──
app.get('/api/soundboard/sounds', (req, res) => {
const q = String(req.query.q ?? '').toLowerCase();
const folderFilter = typeof req.query.folder === 'string' ? req.query.folder : '__all__';
const categoryFilter = typeof req.query.categoryId === 'string' ? String(req.query.categoryId) : undefined;
const useFuzzy = String(req.query.fuzzy ?? '0') === '1';
const allItems = listAllSounds();
const folderCounts = new Map<string, number>();
for (const it of allItems) { if (it.folder) folderCounts.set(it.folder, (folderCounts.get(it.folder) ?? 0) + 1); }
const folders: Array<{ key: string; name: string; count: number }> = [];
for (const [key, count] of folderCounts) folders.push({ key, name: key, count });
const allWithTime = allItems.map(it => {
try { return { ...it, mtimeMs: fs.statSync(path.join(SOUNDS_DIR, it.relativePath)).mtimeMs }; }
catch { return { ...it, mtimeMs: 0 }; }
});
const sortedByNewest = [...allWithTime].sort((a, b) => b.mtimeMs - a.mtimeMs);
const recentTop10 = sortedByNewest.slice(0, 10);
const recentTop5Set = new Set(recentTop10.slice(0, 5).map(x => x.relativePath));
let itemsByFolder = allItems as ListedSound[];
if (folderFilter !== '__all__') {
if (folderFilter === '__recent__') itemsByFolder = recentTop10;
else itemsByFolder = allItems.filter(it => folderFilter === '' ? it.folder === '' : it.folder === folderFilter);
}
function fuzzyScore(text: string, pattern: string): number {
if (!pattern) return 1;
if (text === pattern) return 2000;
const idx = text.indexOf(pattern);
if (idx !== -1) return 1000 + (idx === 0 ? 200 : 0) - idx * 2;
let tI = 0, pI = 0, score = 0, last = -1, gaps = 0, first = -1;
while (tI < text.length && pI < pattern.length) {
if (text[tI] === pattern[pI]) { if (first === -1) first = tI; if (last === tI - 1) score += 5; last = tI; pI++; }
else if (first !== -1) gaps++;
tI++;
}
if (pI !== pattern.length) return 0;
return score + Math.max(0, 300 - first * 2) + Math.max(0, 100 - gaps * 10);
}
let filteredItems = itemsByFolder;
if (q) {
if (useFuzzy) {
filteredItems = itemsByFolder.map(it => ({ it, score: fuzzyScore(it.name.toLowerCase(), q) }))
.filter(x => x.score > 0).sort((a, b) => b.score - a.score || a.it.name.localeCompare(b.it.name))
.map(x => x.it);
} else {
filteredItems = itemsByFolder.filter(s => s.name.toLowerCase().includes(q));
}
}
const playsEntries = Object.entries(persistedState.plays || {});
const top3 = playsEntries.sort((a, b) => (b[1] as number) - (a[1] as number)).slice(0, 3)
.map(([rel, count]) => { const it = allItems.find(i => i.relativePath === rel || i.fileName === rel);
return it ? { key: `__top__:${rel}`, name: `${it.name} (${count})`, count: 1 } : null; })
.filter(Boolean) as Array<{ key: string; name: string; count: number }>;
const foldersOut = [
{ key: '__all__', name: 'Alle', count: allItems.length },
{ key: '__recent__', name: 'Neu', count: Math.min(10, allItems.length) },
...(top3.length ? [{ key: '__top3__', name: 'Most Played (3)', count: top3.length }] : []),
...folders
];
let result = filteredItems;
if (categoryFilter) {
const fc = persistedState.fileCategories ?? {};
result = result.filter(it => (fc[it.relativePath ?? it.fileName] ?? []).includes(categoryFilter));
}
if (folderFilter === '__top3__') {
const keys = new Set(top3.map(t => t.key.split(':')[1]));
result = allItems.filter(i => keys.has(i.relativePath ?? i.fileName));
}
const top3Set = new Set(top3.map(t => t.key.split(':')[1]));
const customBadges = persistedState.fileBadges ?? {};
const withBadges = result.map(it => {
const key = it.relativePath ?? it.fileName;
const badges: string[] = [];
if (recentTop5Set.has(key)) badges.push('new');
if (top3Set.has(key)) badges.push('rocket');
for (const b of (customBadges[key] ?? [])) badges.push(b);
return { ...it, isRecent: recentTop5Set.has(key), badges };
});
res.json({ items: withBadges, total: allItems.length, folders: foldersOut,
categories: persistedState.categories ?? [], fileCategories: persistedState.fileCategories ?? {} });
});
// ── Analytics ──
app.get('/api/soundboard/analytics', (_req, res) => {
const allItems = listAllSounds();
const byKey = new Map<string, ListedSound>();
for (const it of allItems) { byKey.set(it.relativePath, it); if (!byKey.has(it.fileName)) byKey.set(it.fileName, it); }
const mostPlayed = Object.entries(persistedState.plays ?? {})
.map(([rel, count]) => { const it = byKey.get(rel); return it ? { name: it.name, relativePath: it.relativePath, count: Number(count) || 0 } : null; })
.filter((x): x is { name: string; relativePath: string; count: number } => !!x)
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)).slice(0, 10);
res.json({ totalSounds: allItems.length, totalPlays: persistedState.totalPlays ?? 0, mostPlayed });
});
// ── Channels ──
app.get('/api/soundboard/channels', (_req, res) => {
if (!ctx.client.isReady()) { res.status(503).json({ error: 'Bot noch nicht bereit' }); return; }
const allowed = new Set(ctx.allowedGuildIds);
const result: any[] = [];
for (const [, guild] of ctx.client.guilds.cache) {
if (allowed.size > 0 && !allowed.has(guild.id)) continue;
for (const [, ch] of guild.channels.cache) {
if (ch?.type === ChannelType.GuildVoice || ch?.type === ChannelType.GuildStageVoice) {
const sel = persistedState.selectedChannels?.[guild.id];
const members = ('members' in ch)
? (ch as VoiceBasedChannel).members.filter(m => !m.user.bot).size
: 0;
result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name, members, selected: sel === ch.id });
}
}
}
result.sort((a: any, b: any) => a.guildName.localeCompare(b.guildName) || a.channelName.localeCompare(b.channelName));
res.json(result);
});
app.get('/api/soundboard/selected-channels', (_req, res) => {
res.json({ selected: persistedState.selectedChannels ?? {} });
});
app.post('/api/soundboard/selected-channel', async (req, res) => {
const { guildId, channelId } = req.body ?? {};
if (!guildId || !channelId) { res.status(400).json({ error: 'guildId und channelId erforderlich' }); return; }
const guild = ctx.client.guilds.cache.get(guildId);
if (!guild) { res.status(404).json({ error: 'Guild nicht gefunden' }); return; }
const ch = guild.channels.cache.get(channelId);
if (!ch || (ch.type !== ChannelType.GuildVoice && ch.type !== ChannelType.GuildStageVoice)) { res.status(400).json({ error: 'Ungültiger Voice-Channel' }); return; }
if (!persistedState.selectedChannels) persistedState.selectedChannels = {};
persistedState.selectedChannels[guildId] = channelId;
writeState();
sseBroadcast({ type: 'soundboard_channel', plugin: 'soundboard', guildId, channelId });
res.json({ ok: true });
});
// ── Play ──
app.post('/api/soundboard/play', async (req, res) => {
try {
const { soundName, guildId, channelId, volume, folder, relativePath } = req.body ?? {};
console.log(`${SB} POST /play: sound=${soundName} guild=${guildId} channel=${channelId} folder=${folder ?? '-'} relPath=${relativePath ?? '-'}`);
if (!soundName || !guildId || !channelId) { console.warn(`${SB} /play missing params`); res.status(400).json({ error: 'soundName, guildId, channelId erforderlich' }); return; }
let filePath: string;
if (relativePath) filePath = path.join(SOUNDS_DIR, relativePath);
else if (folder) { const mp3 = path.join(SOUNDS_DIR, folder, `${soundName}.mp3`); filePath = fs.existsSync(mp3) ? mp3 : path.join(SOUNDS_DIR, folder, `${soundName}.wav`); }
else { const mp3 = path.join(SOUNDS_DIR, `${soundName}.mp3`); filePath = fs.existsSync(mp3) ? mp3 : path.join(SOUNDS_DIR, `${soundName}.wav`); }
if (!fs.existsSync(filePath)) { console.warn(`${SB} Sound file not found: ${filePath}`); res.status(404).json({ error: 'Sound nicht gefunden' }); return; }
console.log(`${SB} Resolved file: ${filePath}`);
const relKey = relativePath || (folder ? `${folder}/${soundName}` : soundName);
await playFilePath(guildId, channelId, filePath, volume, relKey);
res.json({ ok: true });
} catch (e: any) { console.error(`${SB} /play error: ${e?.message ?? e}`); res.status(500).json({ error: e?.message ?? 'Fehler' }); }
});
/** Shared download logic for play-url and download-url */
async function handleUrlDownload(url: string, customFilename?: string): Promise<{ savedFile: string; savedPath: string }> {
let savedFile: string;
let savedPath: string;
if (isYtDlpUrl(url)) {
console.log(`${SB} [url-dl] → yt-dlp...`);
const result = await downloadWithYtDlp(url);
savedFile = result.filename;
savedPath = result.filepath;
} else {
const parsed = new URL(url);
savedFile = path.basename(parsed.pathname);
savedPath = path.join(SOUNDS_DIR, savedFile);
console.log(`${SB} [url-dl] → direct MP3: ${savedFile}`);
const r = await fetch(url);
if (!r.ok) throw new Error(`Download fehlgeschlagen (HTTP ${r.status})`);
const buf = Buffer.from(await r.arrayBuffer());
fs.writeFileSync(savedPath, buf);
console.log(`${SB} [url-dl] saved ${(buf.length / 1024).toFixed(0)} KB → ${savedFile}`);
}
// Rename if custom filename provided
if (customFilename) {
const safeName = customFilename.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim();
if (safeName) {
const ext = path.extname(savedFile).toLowerCase() || '.mp3';
const newName = safeName.endsWith(ext) ? safeName : safeName + ext;
const newPath = path.join(SOUNDS_DIR, newName);
if (newPath !== savedPath && !fs.existsSync(newPath)) {
fs.renameSync(savedPath, newPath);
console.log(`${SB} [url-dl] renamed: ${savedFile}${newName}`);
savedFile = newName;
savedPath = newPath;
}
}
}
if (NORMALIZE_ENABLE) {
try { await normalizeToCache(savedPath); console.log(`${SB} [url-dl] normalized`); }
catch (e: any) { console.error(`${SB} [url-dl] normalize failed: ${e?.message}`); }
}
return { savedFile, savedPath };
}
app.post('/api/soundboard/play-url', async (req, res) => {
const startTime = Date.now();
try {
const { url, guildId, channelId, volume, filename } = req.body ?? {};
const urlType = isYtDlpUrl(url || '') ? 'yt-dlp' : isDirectMp3Url(url || '') ? 'mp3' : 'unknown';
console.log(`${SB} [play-url] ▶ type=${urlType} url=${url} filename=${filename || '(auto)'} guild=${guildId}`);
if (!url || !guildId || !channelId) { res.status(400).json({ error: 'url, guildId, channelId erforderlich' }); return; }
try { new URL(url); } catch { res.status(400).json({ error: 'Ungültige URL' }); return; }
if (!isSupportedUrl(url)) { res.status(400).json({ error: 'Nur YouTube, Instagram oder direkte MP3-Links' }); return; }
const { savedFile, savedPath } = await handleUrlDownload(url, filename);
try { await playFilePath(guildId, channelId, savedPath, volume, savedFile); console.log(`${SB} [play-url] playing`); }
catch (e: any) { console.error(`${SB} [play-url] play failed (file saved): ${e?.message}`); }
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`${SB} [play-url] ✓ DONE in ${elapsed}s → ${savedFile}`);
res.json({ ok: true, saved: savedFile });
} catch (e: any) {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.error(`${SB} [play-url] ✗ ERROR after ${elapsed}s: ${e?.message ?? e}`);
res.status(500).json({ error: e?.message ?? 'Fehler' });
}
});
app.post('/api/soundboard/download-url', requireAdmin, async (req, res) => {
const startTime = Date.now();
try {
const { url, filename } = req.body ?? {};
const urlType = isYtDlpUrl(url || '') ? 'yt-dlp' : isDirectMp3Url(url || '') ? 'mp3' : 'unknown';
console.log(`${SB} [download-url] ▶ type=${urlType} url=${url} filename=${filename || '(auto)'}`);
if (!url) { res.status(400).json({ error: 'URL erforderlich' }); return; }
try { new URL(url); } catch { res.status(400).json({ error: 'Ungültige URL' }); return; }
if (!isSupportedUrl(url)) { res.status(400).json({ error: 'Nur YouTube, Instagram oder direkte MP3-Links' }); return; }
const { savedFile } = await handleUrlDownload(url, filename);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`${SB} [download-url] ✓ DONE in ${elapsed}s → ${savedFile}`);
res.json({ ok: true, saved: savedFile });
} catch (e: any) {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.error(`${SB} [download-url] ✗ ERROR after ${elapsed}s: ${e?.message ?? e}`);
res.status(500).json({ error: e?.message ?? 'Fehler' });
}
});
// ── Volume ──
app.post('/api/soundboard/volume', (req, res) => {
const { guildId, volume } = req.body ?? {};
if (!guildId || typeof volume !== 'number') { res.status(400).json({ error: 'guildId und volume erforderlich' }); return; }
const safeVol = Math.max(0, Math.min(1, volume));
const state = guildAudioState.get(guildId);
if (state) {
state.currentVolume = safeVol;
if (state.currentResource?.volume) state.currentResource.volume.setVolume(safeVol);
}
persistedState.volumes[guildId] = safeVol;
writeState();
sseBroadcast({ type: 'soundboard_volume', plugin: 'soundboard', guildId, volume: safeVol });
res.json({ ok: true, volume: safeVol });
});
app.get('/api/soundboard/volume', (req, res) => {
const guildId = String(req.query.guildId ?? '');
if (!guildId) { res.status(400).json({ error: 'guildId erforderlich' }); return; }
const state = guildAudioState.get(guildId);
res.json({ volume: state?.currentVolume ?? getPersistedVolume(guildId) });
});
// ── Stop ──
app.post('/api/soundboard/stop', (req, res) => {
const guildId = String((req.query.guildId || req.body?.guildId) ?? '');
if (!guildId) { res.status(400).json({ error: 'guildId erforderlich' }); return; }
const state = guildAudioState.get(guildId);
if (!state) { res.status(404).json({ error: 'Kein aktiver Player' }); return; }
state.player.stop(true);
nowPlaying.delete(guildId);
sseBroadcast({ type: 'soundboard_nowplaying', plugin: 'soundboard', guildId, name: '' });
const t = partyTimers.get(guildId); if (t) clearTimeout(t);
partyTimers.delete(guildId); partyActive.delete(guildId);
sseBroadcast({ type: 'soundboard_party', plugin: 'soundboard', guildId, active: false });
res.json({ ok: true });
});
// ── Party ──
app.post('/api/soundboard/party/start', (req, res) => {
const { guildId, channelId } = req.body ?? {};
if (!guildId || !channelId) { res.status(400).json({ error: 'guildId und channelId erforderlich' }); return; }
const old = partyTimers.get(guildId); if (old) clearTimeout(old); partyTimers.delete(guildId);
schedulePartyPlayback(guildId, channelId);
res.json({ ok: true });
});
app.post('/api/soundboard/party/stop', (req, res) => {
const guildId = String(req.body?.guildId ?? '');
if (!guildId) { res.status(400).json({ error: 'guildId erforderlich' }); return; }
const t = partyTimers.get(guildId); if (t) clearTimeout(t);
partyTimers.delete(guildId); partyActive.delete(guildId);
sseBroadcast({ type: 'soundboard_party', plugin: 'soundboard', guildId, active: false });
res.json({ ok: true });
});
// ── Categories ──
app.get('/api/soundboard/categories', (_req, res) => { res.json({ categories: persistedState.categories ?? [] }); });
app.post('/api/soundboard/categories', requireAdmin, (req, res) => {
const { name, color, sort } = req.body ?? {};
if (!name?.trim()) { res.status(400).json({ error: 'name erforderlich' }); return; }
const cat = { id: crypto.randomUUID(), name: name.trim(), color, sort };
persistedState.categories = [...(persistedState.categories ?? []), cat];
writeState(); res.json({ ok: true, category: cat });
});
app.patch('/api/soundboard/categories/:id', requireAdmin, (req, res) => {
const cats = persistedState.categories ?? [];
const idx = cats.findIndex(c => c.id === req.params.id);
if (idx === -1) { res.status(404).json({ error: 'Nicht gefunden' }); return; }
const { name, color, sort } = req.body ?? {};
if (typeof name === 'string') cats[idx].name = name;
if (typeof color === 'string') cats[idx].color = color;
if (typeof sort === 'number') cats[idx].sort = sort;
writeState(); res.json({ ok: true, category: cats[idx] });
});
app.delete('/api/soundboard/categories/:id', requireAdmin, (req, res) => {
const cats = persistedState.categories ?? [];
if (!cats.find(c => c.id === req.params.id)) { res.status(404).json({ error: 'Nicht gefunden' }); return; }
persistedState.categories = cats.filter(c => c.id !== req.params.id);
const fc = persistedState.fileCategories ?? {};
for (const k of Object.keys(fc)) fc[k] = (fc[k] ?? []).filter(x => x !== req.params.id);
writeState(); res.json({ ok: true });
});
app.post('/api/soundboard/categories/assign', requireAdmin, (req, res) => {
const { files, add, remove } = req.body ?? {};
if (!Array.isArray(files) || !files.length) { res.status(400).json({ error: 'files[] erforderlich' }); return; }
const validCats = new Set((persistedState.categories ?? []).map(c => c.id));
const fc = persistedState.fileCategories ?? {};
for (const rel of files) {
const old = new Set(fc[rel] ?? []);
for (const a of (add ?? []).filter((id: string) => validCats.has(id))) old.add(a);
for (const r of (remove ?? []).filter((id: string) => validCats.has(id))) old.delete(r);
fc[rel] = Array.from(old);
}
persistedState.fileCategories = fc; writeState(); res.json({ ok: true, fileCategories: fc });
});
// ── Badges ──
app.post('/api/soundboard/badges/assign', requireAdmin, (req, res) => {
const { files, add, remove } = req.body ?? {};
if (!Array.isArray(files) || !files.length) { res.status(400).json({ error: 'files[] erforderlich' }); return; }
const fb = persistedState.fileBadges ?? {};
for (const rel of files) { const old = new Set(fb[rel] ?? []);
for (const a of (add ?? [])) old.add(a); for (const r of (remove ?? [])) old.delete(r); fb[rel] = Array.from(old); }
persistedState.fileBadges = fb; writeState(); res.json({ ok: true, fileBadges: fb });
});
app.post('/api/soundboard/badges/clear', requireAdmin, (req, res) => {
const { files } = req.body ?? {};
if (!Array.isArray(files) || !files.length) { res.status(400).json({ error: 'files[] erforderlich' }); return; }
const fb = persistedState.fileBadges ?? {};
for (const rel of files) delete fb[rel];
persistedState.fileBadges = fb; writeState(); res.json({ ok: true, fileBadges: fb });
});
// ── Admin: Delete & Rename ──
app.post('/api/soundboard/admin/sounds/delete', requireAdmin, (req, res) => {
const { paths: pathsList } = req.body ?? {};
if (!Array.isArray(pathsList) || !pathsList.length) { res.status(400).json({ error: 'paths[] erforderlich' }); return; }
const results: any[] = [];
for (const rel of pathsList) {
const full = safeSoundsPath(rel);
if (!full) { results.push({ path: rel, ok: false, error: 'Ungültig' }); continue; }
try {
if (fs.existsSync(full) && fs.statSync(full).isFile()) {
fs.unlinkSync(full);
try { const cf = path.join(NORM_CACHE_DIR, normCacheKey(full)); if (fs.existsSync(cf)) fs.unlinkSync(cf); } catch {}
results.push({ path: rel, ok: true });
} else results.push({ path: rel, ok: false, error: 'nicht gefunden' });
} catch (e: any) { results.push({ path: rel, ok: false, error: e?.message }); }
}
res.json({ ok: true, results });
});
app.post('/api/soundboard/admin/sounds/rename', requireAdmin, (req, res) => {
const { from, to } = req.body ?? {};
if (!from || !to) { res.status(400).json({ error: 'from und to erforderlich' }); return; }
const src = safeSoundsPath(from);
if (!src) { res.status(400).json({ error: 'Ungültiger Quellpfad' }); return; }
const parsed = path.parse(from);
const sanitizedName = to.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
const dstRel = path.join(parsed.dir || '', `${sanitizedName}.mp3`);
const dst = safeSoundsPath(dstRel);
if (!dst) { res.status(400).json({ error: 'Ungültiger Zielpfad' }); return; }
if (!fs.existsSync(src)) { res.status(404).json({ error: 'Quelle nicht gefunden' }); return; }
if (fs.existsSync(dst)) { res.status(409).json({ error: 'Ziel existiert bereits' }); return; }
fs.renameSync(src, dst);
try { const cf = path.join(NORM_CACHE_DIR, normCacheKey(src)); if (fs.existsSync(cf)) fs.unlinkSync(cf); } catch {}
res.json({ ok: true, from, to: dstRel });
});
// ── Upload ──
const uploadStorage = multer.diskStorage({
destination: (_req: any, _file: any, cb: (e: null, dest: string) => void) => cb(null, SOUNDS_DIR),
filename: (_req: any, file: { originalname: string }, cb: (e: null, name: string) => void) => {
const safe = file.originalname.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
const { name, ext } = path.parse(safe);
let finalName = safe; let i = 2;
while (fs.existsSync(path.join(SOUNDS_DIR, finalName))) { finalName = `${name}-${i}${ext}`; i++; }
cb(null, finalName);
},
});
const uploadMulter = multer({ storage: uploadStorage,
fileFilter: (_req: any, file: { originalname: string }, cb: (e: null, ok: boolean) => void) => {
cb(null, /\.(mp3|wav)$/i.test(file.originalname));
}, limits: { fileSize: 50 * 1024 * 1024, files: 20 } });
app.post('/api/soundboard/upload', requireAdmin, (req, res) => {
uploadMulter.array('files', 20)(req, res, async (err: any) => {
if (err) { res.status(400).json({ error: err?.message ?? 'Upload fehlgeschlagen' }); return; }
const files = (req as any).files as any[] | undefined;
if (!files?.length) { res.status(400).json({ error: 'Keine gültigen Dateien' }); return; }
// If a customName was provided, rename the first file
const customName = req.body?.customName?.trim();
if (customName && files.length === 1) {
const sanitized = customName.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
const newFilename = sanitized.endsWith('.mp3') || sanitized.endsWith('.wav')
? sanitized : `${sanitized}.mp3`;
const oldPath = files[0].path;
const newPath = path.join(path.dirname(oldPath), newFilename);
if (!fs.existsSync(newPath)) {
fs.renameSync(oldPath, newPath);
files[0].filename = newFilename;
files[0].path = newPath;
}
}
if (NORMALIZE_ENABLE) { for (const f of files) normalizeToCache(f.path).catch(() => {}); }
res.json({ ok: true, files: files.map(f => ({ name: f.filename, size: f.size })) });
});
});
// ── Health ──
app.get('/api/soundboard/health', (_req, res) => {
res.json({ ok: true, totalPlays: persistedState.totalPlays ?? 0, categories: (persistedState.categories ?? []).length, sounds: listAllSounds().length });
});
// ── SSE Events (soundboard-specific data in main SSE stream) ──
// The main hub SSE already exists at /api/events, snapshot data is provided via getSnapshot()
},
getSnapshot() {
const statsSnap: Record<string, any> = {};
for (const [gId, st] of guildAudioState) {
const status = st.connection.state?.status ?? 'unknown';
if (status === 'ready' && !connectedSince.has(gId)) connectedSince.set(gId, new Date().toISOString());
const ch = _pluginCtx?.client.channels.cache.get(st.channelId);
statsSnap[gId] = { voicePing: (st.connection.ping as any)?.ws ?? null,
gatewayPing: _pluginCtx?.client.ws.ping, status,
channelName: ch && 'name' in ch ? (ch as any).name : null,
connectedSince: connectedSince.get(gId) ?? null };
}
return {
soundboard: {
party: Array.from(partyActive),
selected: persistedState?.selectedChannels ?? {},
volumes: persistedState?.volumes ?? {},
nowplaying: Object.fromEntries(nowPlaying),
voicestats: statsSnap,
},
};
},
async destroy() {
for (const t of partyTimers.values()) clearTimeout(t);
partyTimers.clear(); partyActive.clear();
for (const [gId, state] of guildAudioState) {
try { state.player.stop(true); } catch {}
try { state.connection.destroy(); } catch {}
}
guildAudioState.clear();
if (_writeTimer) { clearTimeout(_writeTimer); writeState(); }
console.log('[Soundboard] Destroyed');
},
};
export default soundboardPlugin;