gaming-hub/server/src/plugins/soundboard/index.ts
Daniel 200f03c1f8 feat(soundboard): extend URL download to support YouTube & Instagram
yt-dlp extracts audio as MP3 from YouTube and Instagram links.
Direct MP3 links continue to work as before. URL input field now shows
a type indicator (YT/IG/MP3) and validates all three formats.

Backend: downloadWithYtDlp() spawns yt-dlp with --extract-audio,
saves to SOUNDS_DIR, normalizes if enabled. New /download-url route
for save-only without auto-play. play-url route extended for all types.

Frontend: isSupportedUrl() validates YouTube/Instagram/MP3, dynamic
icon changes per URL type, disabled state when URL is unsupported.

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

1210 lines
60 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';
// ── 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) => {
// Use yt-dlp to extract audio as best quality MP3
// Output template: title sanitized, placed in SOUNDS_DIR
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
url,
];
console.log(`${SB} yt-dlp downloading: ${url}`);
const proc = child_process.spawn('yt-dlp', args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (d: Buffer) => { stdout += d.toString(); });
proc.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); });
proc.on('error', (err) => {
console.error(`${SB} yt-dlp spawn error:`, err.message);
reject(new Error('yt-dlp nicht verfügbar'));
});
proc.on('close', (code) => {
if (code !== 0) {
console.error(`${SB} yt-dlp failed (code ${code}): ${stderr.slice(0, 500)}`);
// Extract useful error message
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
reject(new Error('Download fehlgeschlagen'));
return;
}
// 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}`);
resolve({ filename, filepath });
return;
}
// Fallback: scan SOUNDS_DIR for newest MP3 (within last 30s)
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 < 30000)
.sort((a, b) => b.mtime - a.mtime);
if (mp3s.length > 0) {
const filename = mp3s[0].name;
console.log(`${SB} yt-dlp saved (fallback detect): ${filename}`);
resolve({ filename, filepath: path.join(SOUNDS_DIR, filename) });
return;
}
console.error(`${SB} yt-dlp: could not find output file. stdout: ${stdout.slice(0, 500)}`);
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);
}
// ── 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];
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' }); }
});
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; }
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; }
let savedFile: string;
let savedPath: string;
if (isYtDlpUrl(url)) {
// YouTube / Instagram → yt-dlp extract audio as MP3
const result = await downloadWithYtDlp(url);
savedFile = result.filename;
savedPath = result.filepath;
} else {
// Direct MP3 link → fetch and save
const parsed = new URL(url);
savedFile = path.basename(parsed.pathname);
savedPath = path.join(SOUNDS_DIR, savedFile);
const r = await fetch(url);
if (!r.ok) { res.status(400).json({ error: 'Download fehlgeschlagen' }); return; }
fs.writeFileSync(savedPath, Buffer.from(await r.arrayBuffer()));
}
if (NORMALIZE_ENABLE) { try { await normalizeToCache(savedPath); } catch {} }
try { await playFilePath(guildId, channelId, savedPath, volume, savedFile); }
catch { /* play failed, but file is saved — that's ok */ }
res.json({ ok: true, saved: savedFile });
} catch (e: any) { res.status(500).json({ error: e?.message ?? 'Fehler' }); }
});
// Download-only route (save without auto-play)
app.post('/api/soundboard/download-url', requireAdmin, async (req, res) => {
try {
const { url } = req.body ?? {};
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; }
let savedFile: string;
let savedPath: string;
if (isYtDlpUrl(url)) {
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);
const r = await fetch(url);
if (!r.ok) { res.status(400).json({ error: 'Download fehlgeschlagen' }); return; }
fs.writeFileSync(savedPath, Buffer.from(await r.arrayBuffer()));
}
if (NORMALIZE_ENABLE) { try { await normalizeToCache(savedPath); } catch {} }
res.json({ ok: true, saved: savedFile });
} 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;