gaming-hub/server/src/plugins/soundboard/index.ts

1058 lines
54 KiB
TypeScript
Raw Normal View History

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 VoiceState, type Message } from 'discord.js';
import type { Plugin, PluginContext } from '../../core/plugin.js';
import { sseBroadcast } from '../../core/sse.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}`)); });
});
}
// ── 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 });
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 });
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 });
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 });
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 });
// 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);
if (current && current.joinConfig?.channelId !== channelId) {
current.destroy();
const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false });
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)) {
const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false });
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);
}
// ── Admin Auth (JWT-like with HMAC) ──
type AdminPayload = { iat: number; exp: number };
function b64url(input: Buffer | string): string {
return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
}
function signAdminToken(adminPwd: string, payload: AdminPayload): string {
const body = b64url(JSON.stringify(payload));
const sig = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url');
return `${body}.${sig}`;
}
function verifyAdminToken(adminPwd: string, token: string | undefined): boolean {
if (!token || !adminPwd) return false;
const [body, sig] = token.split('.');
if (!body || !sig) return false;
const expected = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url');
if (expected !== sig) return false;
try {
const payload = JSON.parse(Buffer.from(body, 'base64').toString('utf8')) as AdminPayload;
return typeof payload.exp === 'number' && Date.now() < payload.exp;
} catch { return false; }
}
function readCookie(req: express.Request, key: string): string | undefined {
const c = req.headers.cookie;
if (!c) return undefined;
for (const part of c.split(';')) { const [k, v] = part.trim().split('='); if (k === key) return decodeURIComponent(v || ''); }
return undefined;
}
// ── 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 = (req: express.Request, res: express.Response, next: () => void) => {
if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; }
if (!verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin'))) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
next();
};
// ── Admin Auth ──
app.post('/api/soundboard/admin/login', (req, res) => {
if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; }
const { password } = req.body ?? {};
if (!password || password !== ctx.adminPwd) { res.status(401).json({ error: 'Falsches Passwort' }); return; }
const token = signAdminToken(ctx.adminPwd, { iat: Date.now(), exp: Date.now() + 7 * 24 * 3600 * 1000 });
res.setHeader('Set-Cookie', `admin=${encodeURIComponent(token)}; HttpOnly; Path=/; Max-Age=${7 * 24 * 3600}; SameSite=Lax`);
res.json({ ok: true });
});
app.post('/api/soundboard/admin/logout', (_req, res) => {
res.setHeader('Set-Cookie', 'admin=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax');
res.json({ ok: true });
});
app.get('/api/soundboard/admin/status', (req, res) => {
res.json({ authenticated: verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin')) });
});
// ── 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];
result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name, 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' }); }
});
app.post('/api/soundboard/play-url', async (req, res) => {
try {
const { url, guildId, channelId, volume } = req.body ?? {};
if (!url || !guildId || !channelId) { res.status(400).json({ error: 'url, guildId, channelId erforderlich' }); return; }
let parsed: URL;
try { parsed = new URL(url); } catch { res.status(400).json({ error: 'Ungültige URL' }); return; }
if (!parsed.pathname.toLowerCase().endsWith('.mp3')) { res.status(400).json({ error: 'Nur MP3-Links' }); return; }
const dest = path.join(SOUNDS_DIR, path.basename(parsed.pathname));
const r = await fetch(url);
if (!r.ok) { res.status(400).json({ error: 'Download fehlgeschlagen' }); return; }
fs.writeFileSync(dest, Buffer.from(await r.arrayBuffer()));
if (NORMALIZE_ENABLE) { try { await normalizeToCache(dest); } catch {} }
try { await playFilePath(guildId, channelId, dest, volume, path.basename(dest)); }
catch { res.status(500).json({ error: 'Abspielen fehlgeschlagen' }); return; }
res.json({ ok: true, saved: path.basename(dest) });
} catch (e: any) { 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 (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;