- Add download modal: filename input, progress phases (input/downloading/done/error) - Refactor backend: shared handleUrlDownload() with optional custom filename + rename - Fix Dockerfile: use yt-dlp_linux standalone binary (no Python dependency) - Modal shows URL type badge (YouTube/Instagram/MP3), spinner, retry on error Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1275 lines
63 KiB
TypeScript
1275 lines
63 KiB
TypeScript
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) => {
|
||
const outputTemplate = path.join(SOUNDS_DIR, '%(title)s.%(ext)s');
|
||
const args = [
|
||
'-x', // extract audio only
|
||
'--audio-format', 'mp3', // convert to MP3
|
||
'--audio-quality', '0', // best quality
|
||
'-o', outputTemplate, // output path template
|
||
'--no-playlist', // single video only
|
||
'--no-overwrites', // don't overwrite existing
|
||
'--restrict-filenames', // safe filenames (ASCII, no spaces)
|
||
'--max-filesize', '50m', // same limit as file upload
|
||
'--socket-timeout', '30', // timeout for slow connections
|
||
'--verbose', // verbose output for logging
|
||
url,
|
||
];
|
||
|
||
const startTime = Date.now();
|
||
console.log(`${SB} [yt-dlp] ▶ START url=${url}`);
|
||
console.log(`${SB} [yt-dlp] args: yt-dlp ${args.join(' ')}`);
|
||
|
||
const proc = child_process.spawn('yt-dlp', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||
|
||
let stdout = '';
|
||
let stderr = '';
|
||
proc.stdout?.on('data', (d: Buffer) => {
|
||
const line = d.toString();
|
||
stdout += line;
|
||
// Stream yt-dlp progress to console in real-time
|
||
for (const l of line.split('\n').filter((s: string) => s.trim())) {
|
||
console.log(`${SB} [yt-dlp:out] ${l.trim()}`);
|
||
}
|
||
});
|
||
proc.stderr?.on('data', (d: Buffer) => {
|
||
const line = d.toString();
|
||
stderr += line;
|
||
for (const l of line.split('\n').filter((s: string) => s.trim())) {
|
||
console.error(`${SB} [yt-dlp:err] ${l.trim()}`);
|
||
}
|
||
});
|
||
|
||
proc.on('error', (err) => {
|
||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||
console.error(`${SB} [yt-dlp] ✗ SPAWN ERROR after ${elapsed}s: ${err.message}`);
|
||
reject(new Error('yt-dlp nicht verfügbar'));
|
||
});
|
||
|
||
proc.on('close', (code) => {
|
||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||
|
||
if (code !== 0) {
|
||
console.error(`${SB} [yt-dlp] ✗ FAILED exit=${code} after ${elapsed}s`);
|
||
console.error(`${SB} [yt-dlp] stderr (last 1000 chars): ${stderr.slice(-1000)}`);
|
||
console.error(`${SB} [yt-dlp] stdout (last 500 chars): ${stdout.slice(-500)}`);
|
||
|
||
// Extract useful error for frontend
|
||
if (stderr.includes('Video unavailable') || stderr.includes('is not available'))
|
||
reject(new Error('Video nicht verfügbar'));
|
||
else if (stderr.includes('Private video'))
|
||
reject(new Error('Privates Video'));
|
||
else if (stderr.includes('Sign in') || stderr.includes('login'))
|
||
reject(new Error('Login erforderlich'));
|
||
else if (stderr.includes('exceeds maximum'))
|
||
reject(new Error('Datei zu groß (max 50 MB)'));
|
||
else if (stderr.includes('Unsupported URL'))
|
||
reject(new Error('URL nicht unterstützt'));
|
||
else if (stderr.includes('HTTP Error 404'))
|
||
reject(new Error('Video nicht gefunden (404)'));
|
||
else if (stderr.includes('HTTP Error 403'))
|
||
reject(new Error('Zugriff verweigert (403)'));
|
||
else
|
||
reject(new Error(`yt-dlp Fehler (exit ${code})`));
|
||
return;
|
||
}
|
||
|
||
console.log(`${SB} [yt-dlp] ✓ DONE exit=0 after ${elapsed}s`);
|
||
|
||
// Find the downloaded MP3 file — yt-dlp prints the final filename
|
||
const destMatch = stdout.match(/\[ExtractAudio\] Destination: (.+\.mp3)/i)
|
||
?? stdout.match(/\[download\] (.+\.mp3) has already been downloaded/i)
|
||
?? stdout.match(/Destination: (.+\.mp3)/i);
|
||
|
||
if (destMatch) {
|
||
const filepath = destMatch[1].trim();
|
||
const filename = path.basename(filepath);
|
||
console.log(`${SB} [yt-dlp] saved: ${filename} (regex match)`);
|
||
resolve({ filename, filepath });
|
||
return;
|
||
}
|
||
|
||
// Fallback: scan SOUNDS_DIR for newest MP3 (within last 60s)
|
||
const now = Date.now();
|
||
const mp3s = fs.readdirSync(SOUNDS_DIR)
|
||
.filter(f => f.endsWith('.mp3'))
|
||
.map(f => ({ name: f, mtime: fs.statSync(path.join(SOUNDS_DIR, f)).mtimeMs }))
|
||
.filter(f => now - f.mtime < 60000)
|
||
.sort((a, b) => b.mtime - a.mtime);
|
||
|
||
if (mp3s.length > 0) {
|
||
const filename = mp3s[0].name;
|
||
console.log(`${SB} [yt-dlp] saved: ${filename} (fallback scan, ${mp3s.length} recent files)`);
|
||
resolve({ filename, filepath: path.join(SOUNDS_DIR, filename) });
|
||
return;
|
||
}
|
||
|
||
console.error(`${SB} [yt-dlp] ✗ OUTPUT FILE NOT FOUND`);
|
||
console.error(`${SB} [yt-dlp] full stdout:\n${stdout}`);
|
||
console.error(`${SB} [yt-dlp] full stderr:\n${stderr}`);
|
||
reject(new Error('Download abgeschlossen, aber Datei nicht gefunden'));
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── PCM Memory Cache ──
|
||
const pcmMemoryCache = new Map<string, Buffer>();
|
||
let pcmMemoryCacheBytes = 0;
|
||
|
||
function getPcmFromMemory(cachedPath: string): Buffer | null {
|
||
const buf = pcmMemoryCache.get(cachedPath);
|
||
if (buf) return buf;
|
||
try {
|
||
const stat = fs.statSync(cachedPath);
|
||
if (stat.size > PCM_PER_FILE_MAX_MB * 1024 * 1024) return null;
|
||
if (pcmMemoryCacheBytes + stat.size > PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) return null;
|
||
const data = fs.readFileSync(cachedPath);
|
||
pcmMemoryCache.set(cachedPath, data);
|
||
pcmMemoryCacheBytes += data.byteLength;
|
||
return data;
|
||
} catch { return null; }
|
||
}
|
||
|
||
function invalidatePcmMemory(cachedPath: string): void {
|
||
const buf = pcmMemoryCache.get(cachedPath);
|
||
if (buf) { pcmMemoryCacheBytes -= buf.byteLength; pcmMemoryCache.delete(cachedPath); }
|
||
}
|
||
|
||
// ── Sound listing ──
|
||
function listAllSounds(): ListedSound[] {
|
||
const rootEntries = fs.readdirSync(SOUNDS_DIR, { withFileTypes: true });
|
||
const rootFiles: ListedSound[] = rootEntries
|
||
.filter(d => d.isFile() && /\.(mp3|wav)$/i.test(d.name))
|
||
.map(d => ({ fileName: d.name, name: path.parse(d.name).name, folder: '', relativePath: d.name }));
|
||
|
||
const folderItems: ListedSound[] = [];
|
||
for (const dirent of rootEntries.filter(d => d.isDirectory() && d.name !== '.norm-cache')) {
|
||
const folderPath = path.join(SOUNDS_DIR, dirent.name);
|
||
for (const e of fs.readdirSync(folderPath, { withFileTypes: true })) {
|
||
if (!e.isFile() || !/\.(mp3|wav)$/i.test(e.name)) continue;
|
||
folderItems.push({ fileName: e.name, name: path.parse(e.name).name, folder: dirent.name,
|
||
relativePath: path.join(dirent.name, e.name) });
|
||
}
|
||
}
|
||
return [...rootFiles, ...folderItems].sort((a, b) => a.name.localeCompare(b.name));
|
||
}
|
||
|
||
// ── Norm cache sync ──
|
||
async function syncNormCache(): Promise<void> {
|
||
if (!NORMALIZE_ENABLE) return;
|
||
const t0 = Date.now();
|
||
const allSounds = listAllSounds();
|
||
const expectedKeys = new Set<string>();
|
||
const toProcess: string[] = [];
|
||
|
||
for (const s of allSounds) {
|
||
const fp = path.join(SOUNDS_DIR, s.relativePath);
|
||
const key = normCacheKey(fp);
|
||
expectedKeys.add(key);
|
||
if (!fs.existsSync(fp)) continue;
|
||
if (getNormCachePath(fp)) continue;
|
||
toProcess.push(fp);
|
||
}
|
||
|
||
let created = 0, failed = 0;
|
||
const skipped = allSounds.length - toProcess.length;
|
||
const queue = [...toProcess];
|
||
async function worker(): Promise<void> {
|
||
while (queue.length > 0) {
|
||
const fp = queue.shift()!;
|
||
try { await normalizeToCache(fp); created++; }
|
||
catch (e) { failed++; console.warn(`[Soundboard] norm-cache failed: ${path.relative(SOUNDS_DIR, fp)}`, e); }
|
||
}
|
||
}
|
||
await Promise.all(Array.from({ length: Math.min(NORM_CONCURRENCY, toProcess.length || 1) }, worker));
|
||
|
||
let cleaned = 0;
|
||
try {
|
||
for (const f of fs.readdirSync(NORM_CACHE_DIR)) {
|
||
if (!expectedKeys.has(f)) { try { fs.unlinkSync(path.join(NORM_CACHE_DIR, f)); cleaned++; } catch {} }
|
||
}
|
||
} catch {}
|
||
|
||
console.log(`[Soundboard] Norm-cache sync (${((Date.now() - t0) / 1000).toFixed(1)}s): ${created} new, ${skipped} cached, ${failed} failed, ${cleaned} orphans`);
|
||
}
|
||
|
||
// ── Audio State ──
|
||
const guildAudioState = new Map<string, GuildAudioState>();
|
||
const partyTimers = new Map<string, NodeJS.Timeout>();
|
||
const partyActive = new Set<string>();
|
||
const nowPlaying = new Map<string, string>();
|
||
const connectedSince = new Map<string, string>();
|
||
|
||
// ── Logging helper ──
|
||
const SB = '[Soundboard]';
|
||
|
||
// ── Voice Lifecycle ──
|
||
async function ensureConnectionReady(connection: VoiceConnection, channelId: string, guildId: string, guild: any): Promise<VoiceConnection> {
|
||
console.log(`${SB} ensureConnectionReady: guild=${guildId} channel=${channelId} status=${connection.state.status}`);
|
||
try { await entersState(connection, VoiceConnectionStatus.Ready, 15_000); console.log(`${SB} Connection ready (attempt 1)`); return connection; }
|
||
catch (e) { console.warn(`${SB} Attempt 1 failed: ${(e as Error)?.message ?? e}`); }
|
||
try { connection.rejoin({ channelId, selfDeaf: false, selfMute: false }); await entersState(connection, VoiceConnectionStatus.Ready, 15_000); console.log(`${SB} Connection ready (rejoin)`); return connection; }
|
||
catch (e) { console.warn(`${SB} Rejoin failed: ${(e as Error)?.message ?? e}`); }
|
||
try { connection.destroy(); } catch {}
|
||
guildAudioState.delete(guildId);
|
||
console.log(`${SB} Creating fresh connection (attempt 3)...`);
|
||
const newConn = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
|
||
newConn.on('stateChange', (o: any, n: any) => console.log(`${SB} [fresh-conn] ${o.status} → ${n.status}`));
|
||
newConn.on('error', (err: any) => console.error(`${SB} [fresh-conn] ERROR: ${err?.message ?? err}`));
|
||
try { await entersState(newConn, VoiceConnectionStatus.Ready, 15_000); console.log(`${SB} Connection ready (fresh)`); return newConn; }
|
||
catch (e) { console.error(`${SB} All 3 connection attempts failed: ${(e as Error)?.message ?? e}`); try { newConn.destroy(); } catch {} guildAudioState.delete(guildId); throw new Error('Voice connection failed after 3 attempts'); }
|
||
}
|
||
|
||
function attachVoiceLifecycle(state: GuildAudioState, guild: any) {
|
||
const { connection } = state;
|
||
if ((connection as any).__lifecycleAttached) return;
|
||
try { (connection as any).setMaxListeners?.(0); } catch {}
|
||
let reconnectAttempts = 0;
|
||
const MAX_RECONNECT_ATTEMPTS = 3;
|
||
let isReconnecting = false;
|
||
|
||
connection.on('stateChange', async (oldS: any, newS: any) => {
|
||
console.log(`${SB} Voice state: ${oldS.status} → ${newS.status} (guild=${state.guildId})`);
|
||
if (newS.status === VoiceConnectionStatus.Ready) {
|
||
reconnectAttempts = 0; isReconnecting = false;
|
||
if (!connectedSince.has(state.guildId)) connectedSince.set(state.guildId, new Date().toISOString());
|
||
console.log(`${SB} Voice READY for guild=${state.guildId}`);
|
||
return;
|
||
}
|
||
if (isReconnecting) { console.log(`${SB} Already reconnecting, skipping ${newS.status}`); return; }
|
||
try {
|
||
if (newS.status === VoiceConnectionStatus.Disconnected) {
|
||
console.warn(`${SB} Disconnected – waiting for Signalling/Connecting...`);
|
||
try { await Promise.race([entersState(connection, VoiceConnectionStatus.Signalling, 5_000), entersState(connection, VoiceConnectionStatus.Connecting, 5_000)]); }
|
||
catch {
|
||
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { reconnectAttempts++; console.log(`${SB} Rejoin attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`); connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); }
|
||
else { reconnectAttempts = 0; console.log(`${SB} Max reconnect attempts reached, creating fresh connection`); try { connection.destroy(); } catch {}
|
||
const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
|
||
state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild); }
|
||
}
|
||
} else if (newS.status === VoiceConnectionStatus.Destroyed) {
|
||
console.warn(`${SB} Connection destroyed, recreating...`);
|
||
connectedSince.delete(state.guildId);
|
||
const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
|
||
state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild);
|
||
} else if (newS.status === VoiceConnectionStatus.Connecting || newS.status === VoiceConnectionStatus.Signalling) {
|
||
isReconnecting = true;
|
||
console.log(`${SB} Waiting for Ready from ${newS.status}...`);
|
||
try { await entersState(connection, VoiceConnectionStatus.Ready, 15_000); }
|
||
catch (e) {
|
||
reconnectAttempts++;
|
||
console.warn(`${SB} Timeout waiting for Ready from ${newS.status} (attempt ${reconnectAttempts}): ${(e as Error)?.message ?? e}`);
|
||
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { await new Promise(r => setTimeout(r, reconnectAttempts * 2000)); isReconnecting = false; connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); }
|
||
else { reconnectAttempts = 0; isReconnecting = false; console.error(`${SB} Max attempts from ${newS.status}, fresh connection`); try { connection.destroy(); } catch {}
|
||
const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
|
||
state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild); }
|
||
}
|
||
}
|
||
} catch (e) { console.error(`${SB} Lifecycle error: ${(e as Error)?.message ?? e}`); isReconnecting = false; }
|
||
});
|
||
(connection as any).__lifecycleAttached = true;
|
||
}
|
||
|
||
// ── Debug adapter wrapper ──
|
||
// voiceAdapterCreator(libraryMethods) → { sendPayload, destroy }
|
||
// libraryMethods = { onVoiceServerUpdate, onVoiceStateUpdate, destroy }
|
||
// returned adapter = { sendPayload(payload) → boolean, destroy() }
|
||
function debugAdapterCreator(guild: any): any {
|
||
const original = guild.voiceAdapterCreator;
|
||
return (libraryMethods: any) => {
|
||
// Wrap library methods to log when Discord gateway events arrive
|
||
const wrappedLibraryMethods = {
|
||
...libraryMethods,
|
||
onVoiceServerUpdate(data: any) {
|
||
console.log(`${SB} ← onVoiceServerUpdate: token=${data?.token ? 'yes' : 'no'} endpoint=${data?.endpoint ?? 'none'}`);
|
||
return libraryMethods.onVoiceServerUpdate(data);
|
||
},
|
||
onVoiceStateUpdate(data: any) {
|
||
console.log(`${SB} ← onVoiceStateUpdate: session_id=${data?.session_id ? 'yes' : 'no'} channel_id=${data?.channel_id ?? 'none'}`);
|
||
return libraryMethods.onVoiceStateUpdate(data);
|
||
},
|
||
};
|
||
// Call original adapter creator with our wrapped library methods
|
||
const adapter = original(wrappedLibraryMethods);
|
||
// Wrap the adapter's sendPayload to log outgoing gateway commands
|
||
const origSend = adapter.sendPayload.bind(adapter);
|
||
adapter.sendPayload = (payload: any) => {
|
||
const result = origSend(payload);
|
||
console.log(`${SB} → sendPayload op=${payload?.op ?? '?'} guild=${payload?.d?.guild_id ?? '?'} channel=${payload?.d?.channel_id ?? '?'} → ${result}`);
|
||
return result;
|
||
};
|
||
return adapter;
|
||
};
|
||
}
|
||
|
||
// ── Playback ──
|
||
let _pluginCtx: PluginContext | null = null;
|
||
|
||
async function playFilePath(guildId: string, channelId: string, filePath: string, volume?: number, relativeKey?: string): Promise<void> {
|
||
console.log(`${SB} playFilePath: guild=${guildId} channel=${channelId} file=${path.basename(filePath)} vol=${volume ?? 'default'}`);
|
||
const ctx = _pluginCtx!;
|
||
const guild = ctx.client.guilds.cache.get(guildId);
|
||
if (!guild) { console.error(`${SB} Guild ${guildId} not found in cache (cached: ${ctx.client.guilds.cache.map(g => g.id).join(', ')})`); throw new Error('Guild nicht gefunden'); }
|
||
|
||
let state = guildAudioState.get(guildId);
|
||
if (!state) {
|
||
console.log(`${SB} No existing audio state, creating new connection...`);
|
||
const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
|
||
// Debug: catch ALL state transitions and errors from the start
|
||
connection.on('stateChange', (o: any, n: any) => {
|
||
console.log(`${SB} [conn] ${o.status} → ${n.status}`);
|
||
// Log networking info if available
|
||
if (n.networking) console.log(`${SB} [conn] networking state: ${n.networking?.state?.code ?? 'unknown'}`);
|
||
});
|
||
connection.on('error', (err: any) => console.error(`${SB} [conn] ERROR: ${err?.message ?? err}`));
|
||
console.log(`${SB} Connection created, initial status=${connection.state.status}, state keys=${Object.keys(connection.state).join(',')}`);
|
||
const player = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } });
|
||
connection.subscribe(player);
|
||
state = { connection, player, guildId, channelId, currentVolume: getPersistedVolume(guildId) };
|
||
guildAudioState.set(guildId, state);
|
||
state.connection = await ensureConnectionReady(connection, channelId, guildId, guild);
|
||
attachVoiceLifecycle(state, guild);
|
||
console.log(`${SB} New voice connection established`);
|
||
} else {
|
||
console.log(`${SB} Existing audio state found, connection status=${state.connection.state.status}`);
|
||
}
|
||
|
||
// Channel-Wechsel
|
||
try {
|
||
const current = getVoiceConnection(guildId, 'soundboard');
|
||
if (current && current.joinConfig?.channelId !== channelId) {
|
||
current.destroy();
|
||
const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
|
||
const player = state.player ?? createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } });
|
||
connection.subscribe(player);
|
||
state = { connection, player, guildId, channelId, currentVolume: state.currentVolume ?? getPersistedVolume(guildId) };
|
||
guildAudioState.set(guildId, state);
|
||
state.connection = await ensureConnectionReady(connection, channelId, guildId, guild);
|
||
attachVoiceLifecycle(state, guild);
|
||
}
|
||
} catch {}
|
||
|
||
if (!getVoiceConnection(guildId, 'soundboard')) {
|
||
const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
|
||
const player = state?.player ?? createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } });
|
||
connection.subscribe(player);
|
||
state = { connection, player, guildId, channelId, currentVolume: state?.currentVolume ?? getPersistedVolume(guildId) };
|
||
guildAudioState.set(guildId, state);
|
||
state.connection = await ensureConnectionReady(connection, channelId, guildId, guild);
|
||
attachVoiceLifecycle(state, guild);
|
||
}
|
||
|
||
const useVolume = typeof volume === 'number' && Number.isFinite(volume)
|
||
? Math.max(0, Math.min(1, volume))
|
||
: (state.currentVolume ?? 1);
|
||
|
||
let resource: AudioResource;
|
||
if (NORMALIZE_ENABLE) {
|
||
const cachedPath = getNormCachePath(filePath);
|
||
if (cachedPath) {
|
||
const pcmBuf = getPcmFromMemory(cachedPath);
|
||
if (pcmBuf) {
|
||
resource = createAudioResource(Readable.from(pcmBuf), { inlineVolume: useVolume !== 1, inputType: StreamType.Raw });
|
||
} else {
|
||
resource = createAudioResource(fs.createReadStream(cachedPath, { highWaterMark: 256 * 1024 }), { inlineVolume: true, inputType: StreamType.Raw });
|
||
}
|
||
} else {
|
||
const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath));
|
||
const ff = child_process.spawn('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-i', filePath,
|
||
'-af', `loudnorm=I=${NORMALIZE_I}:LRA=${NORMALIZE_LRA}:TP=${NORMALIZE_TP}`,
|
||
'-f', 's16le', '-ar', '48000', '-ac', '2', 'pipe:1']);
|
||
const playerStream = new PassThrough();
|
||
const cacheWrite = fs.createWriteStream(cacheFile);
|
||
ff.stdout.on('data', (chunk: Buffer) => { playerStream.write(chunk); cacheWrite.write(chunk); });
|
||
ff.stdout.on('end', () => {
|
||
playerStream.end(); cacheWrite.end();
|
||
try { const buf = fs.readFileSync(cacheFile);
|
||
if (pcmMemoryCacheBytes + buf.byteLength <= PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) { pcmMemoryCache.set(cacheFile, buf); pcmMemoryCacheBytes += buf.byteLength; }
|
||
} catch {}
|
||
});
|
||
ff.on('error', () => { try { fs.unlinkSync(cacheFile); } catch {} });
|
||
ff.on('close', (code) => { if (code !== 0) { try { fs.unlinkSync(cacheFile); } catch {} } });
|
||
resource = createAudioResource(playerStream, { inlineVolume: true, inputType: StreamType.Raw });
|
||
}
|
||
} else {
|
||
resource = createAudioResource(filePath, { inlineVolume: true });
|
||
}
|
||
|
||
if (resource.volume) resource.volume.setVolume(useVolume);
|
||
state.player.stop();
|
||
console.log(`${SB} Playing resource: vol=${useVolume} normalized=${NORMALIZE_ENABLE} connStatus=${state.connection.state.status}`);
|
||
|
||
// Log player errors
|
||
state.player.removeAllListeners('error');
|
||
state.player.on('error', (err: any) => {
|
||
console.error(`${SB} AudioPlayer error: ${err?.message ?? err}`);
|
||
if (err?.resource?.metadata) console.error(`${SB} resource metadata:`, err.resource.metadata);
|
||
});
|
||
state.player.on('stateChange', (oldS: any, newS: any) => {
|
||
if (oldS.status !== newS.status) console.log(`${SB} Player state: ${oldS.status} → ${newS.status}`);
|
||
});
|
||
|
||
state.player.play(resource);
|
||
state.currentResource = resource;
|
||
state.currentVolume = useVolume;
|
||
|
||
const soundLabel = relativeKey ? path.parse(relativeKey).name : path.parse(filePath).name;
|
||
nowPlaying.set(guildId, soundLabel);
|
||
console.log(`${SB} Now playing: "${soundLabel}" in guild=${guildId}`);
|
||
sseBroadcast({ type: 'soundboard_nowplaying', plugin: 'soundboard', guildId, name: soundLabel });
|
||
if (relativeKey) incrementPlaysFor(relativeKey);
|
||
}
|
||
|
||
// ── 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' }); }
|
||
});
|
||
|
||
/** Shared download logic for play-url and download-url */
|
||
async function handleUrlDownload(url: string, customFilename?: string): Promise<{ savedFile: string; savedPath: string }> {
|
||
let savedFile: string;
|
||
let savedPath: string;
|
||
|
||
if (isYtDlpUrl(url)) {
|
||
console.log(`${SB} [url-dl] → yt-dlp...`);
|
||
const result = await downloadWithYtDlp(url);
|
||
savedFile = result.filename;
|
||
savedPath = result.filepath;
|
||
} else {
|
||
const parsed = new URL(url);
|
||
savedFile = path.basename(parsed.pathname);
|
||
savedPath = path.join(SOUNDS_DIR, savedFile);
|
||
console.log(`${SB} [url-dl] → direct MP3: ${savedFile}`);
|
||
const r = await fetch(url);
|
||
if (!r.ok) throw new Error(`Download fehlgeschlagen (HTTP ${r.status})`);
|
||
const buf = Buffer.from(await r.arrayBuffer());
|
||
fs.writeFileSync(savedPath, buf);
|
||
console.log(`${SB} [url-dl] saved ${(buf.length / 1024).toFixed(0)} KB → ${savedFile}`);
|
||
}
|
||
|
||
// Rename if custom filename provided
|
||
if (customFilename) {
|
||
const safeName = customFilename.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim();
|
||
if (safeName) {
|
||
const ext = path.extname(savedFile).toLowerCase() || '.mp3';
|
||
const newName = safeName.endsWith(ext) ? safeName : safeName + ext;
|
||
const newPath = path.join(SOUNDS_DIR, newName);
|
||
if (newPath !== savedPath && !fs.existsSync(newPath)) {
|
||
fs.renameSync(savedPath, newPath);
|
||
console.log(`${SB} [url-dl] renamed: ${savedFile} → ${newName}`);
|
||
savedFile = newName;
|
||
savedPath = newPath;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (NORMALIZE_ENABLE) {
|
||
try { await normalizeToCache(savedPath); console.log(`${SB} [url-dl] normalized`); }
|
||
catch (e: any) { console.error(`${SB} [url-dl] normalize failed: ${e?.message}`); }
|
||
}
|
||
|
||
return { savedFile, savedPath };
|
||
}
|
||
|
||
app.post('/api/soundboard/play-url', async (req, res) => {
|
||
const startTime = Date.now();
|
||
try {
|
||
const { url, guildId, channelId, volume, filename } = req.body ?? {};
|
||
const urlType = isYtDlpUrl(url || '') ? 'yt-dlp' : isDirectMp3Url(url || '') ? 'mp3' : 'unknown';
|
||
console.log(`${SB} [play-url] ▶ type=${urlType} url=${url} filename=${filename || '(auto)'} guild=${guildId}`);
|
||
|
||
if (!url || !guildId || !channelId) { res.status(400).json({ error: 'url, guildId, channelId erforderlich' }); return; }
|
||
try { new URL(url); } catch { res.status(400).json({ error: 'Ungültige URL' }); return; }
|
||
if (!isSupportedUrl(url)) { res.status(400).json({ error: 'Nur YouTube, Instagram oder direkte MP3-Links' }); return; }
|
||
|
||
const { savedFile, savedPath } = await handleUrlDownload(url, filename);
|
||
|
||
try { await playFilePath(guildId, channelId, savedPath, volume, savedFile); console.log(`${SB} [play-url] playing`); }
|
||
catch (e: any) { console.error(`${SB} [play-url] play failed (file saved): ${e?.message}`); }
|
||
|
||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||
console.log(`${SB} [play-url] ✓ DONE in ${elapsed}s → ${savedFile}`);
|
||
res.json({ ok: true, saved: savedFile });
|
||
} catch (e: any) {
|
||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||
console.error(`${SB} [play-url] ✗ ERROR after ${elapsed}s: ${e?.message ?? e}`);
|
||
res.status(500).json({ error: e?.message ?? 'Fehler' });
|
||
}
|
||
});
|
||
|
||
app.post('/api/soundboard/download-url', requireAdmin, async (req, res) => {
|
||
const startTime = Date.now();
|
||
try {
|
||
const { url, filename } = req.body ?? {};
|
||
const urlType = isYtDlpUrl(url || '') ? 'yt-dlp' : isDirectMp3Url(url || '') ? 'mp3' : 'unknown';
|
||
console.log(`${SB} [download-url] ▶ type=${urlType} url=${url} filename=${filename || '(auto)'}`);
|
||
|
||
if (!url) { res.status(400).json({ error: 'URL erforderlich' }); return; }
|
||
try { new URL(url); } catch { res.status(400).json({ error: 'Ungültige URL' }); return; }
|
||
if (!isSupportedUrl(url)) { res.status(400).json({ error: 'Nur YouTube, Instagram oder direkte MP3-Links' }); return; }
|
||
|
||
const { savedFile } = await handleUrlDownload(url, filename);
|
||
|
||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||
console.log(`${SB} [download-url] ✓ DONE in ${elapsed}s → ${savedFile}`);
|
||
res.json({ ok: true, saved: savedFile });
|
||
} catch (e: any) {
|
||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||
console.error(`${SB} [download-url] ✗ ERROR after ${elapsed}s: ${e?.message ?? e}`);
|
||
res.status(500).json({ error: e?.message ?? 'Fehler' });
|
||
}
|
||
});
|
||
|
||
// ── Volume ──
|
||
app.post('/api/soundboard/volume', (req, res) => {
|
||
const { guildId, volume } = req.body ?? {};
|
||
if (!guildId || typeof volume !== 'number') { res.status(400).json({ error: 'guildId und volume erforderlich' }); return; }
|
||
const safeVol = Math.max(0, Math.min(1, volume));
|
||
const state = guildAudioState.get(guildId);
|
||
if (state) {
|
||
state.currentVolume = safeVol;
|
||
if (state.currentResource?.volume) state.currentResource.volume.setVolume(safeVol);
|
||
}
|
||
persistedState.volumes[guildId] = safeVol;
|
||
writeState();
|
||
sseBroadcast({ type: 'soundboard_volume', plugin: 'soundboard', guildId, volume: safeVol });
|
||
res.json({ ok: true, volume: safeVol });
|
||
});
|
||
|
||
app.get('/api/soundboard/volume', (req, res) => {
|
||
const guildId = String(req.query.guildId ?? '');
|
||
if (!guildId) { res.status(400).json({ error: 'guildId erforderlich' }); return; }
|
||
const state = guildAudioState.get(guildId);
|
||
res.json({ volume: state?.currentVolume ?? getPersistedVolume(guildId) });
|
||
});
|
||
|
||
// ── Stop ──
|
||
app.post('/api/soundboard/stop', (req, res) => {
|
||
const guildId = String((req.query.guildId || req.body?.guildId) ?? '');
|
||
if (!guildId) { res.status(400).json({ error: 'guildId erforderlich' }); return; }
|
||
const state = guildAudioState.get(guildId);
|
||
if (!state) { res.status(404).json({ error: 'Kein aktiver Player' }); return; }
|
||
state.player.stop(true);
|
||
nowPlaying.delete(guildId);
|
||
sseBroadcast({ type: 'soundboard_nowplaying', plugin: 'soundboard', guildId, name: '' });
|
||
const t = partyTimers.get(guildId); if (t) clearTimeout(t);
|
||
partyTimers.delete(guildId); partyActive.delete(guildId);
|
||
sseBroadcast({ type: 'soundboard_party', plugin: 'soundboard', guildId, active: false });
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
// ── Party ──
|
||
app.post('/api/soundboard/party/start', (req, res) => {
|
||
const { guildId, channelId } = req.body ?? {};
|
||
if (!guildId || !channelId) { res.status(400).json({ error: 'guildId und channelId erforderlich' }); return; }
|
||
const old = partyTimers.get(guildId); if (old) clearTimeout(old); partyTimers.delete(guildId);
|
||
schedulePartyPlayback(guildId, channelId);
|
||
res.json({ ok: true });
|
||
});
|
||
app.post('/api/soundboard/party/stop', (req, res) => {
|
||
const guildId = String(req.body?.guildId ?? '');
|
||
if (!guildId) { res.status(400).json({ error: 'guildId erforderlich' }); return; }
|
||
const t = partyTimers.get(guildId); if (t) clearTimeout(t);
|
||
partyTimers.delete(guildId); partyActive.delete(guildId);
|
||
sseBroadcast({ type: 'soundboard_party', plugin: 'soundboard', guildId, active: false });
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
// ── Categories ──
|
||
app.get('/api/soundboard/categories', (_req, res) => { res.json({ categories: persistedState.categories ?? [] }); });
|
||
app.post('/api/soundboard/categories', requireAdmin, (req, res) => {
|
||
const { name, color, sort } = req.body ?? {};
|
||
if (!name?.trim()) { res.status(400).json({ error: 'name erforderlich' }); return; }
|
||
const cat = { id: crypto.randomUUID(), name: name.trim(), color, sort };
|
||
persistedState.categories = [...(persistedState.categories ?? []), cat];
|
||
writeState(); res.json({ ok: true, category: cat });
|
||
});
|
||
app.patch('/api/soundboard/categories/:id', requireAdmin, (req, res) => {
|
||
const cats = persistedState.categories ?? [];
|
||
const idx = cats.findIndex(c => c.id === req.params.id);
|
||
if (idx === -1) { res.status(404).json({ error: 'Nicht gefunden' }); return; }
|
||
const { name, color, sort } = req.body ?? {};
|
||
if (typeof name === 'string') cats[idx].name = name;
|
||
if (typeof color === 'string') cats[idx].color = color;
|
||
if (typeof sort === 'number') cats[idx].sort = sort;
|
||
writeState(); res.json({ ok: true, category: cats[idx] });
|
||
});
|
||
app.delete('/api/soundboard/categories/:id', requireAdmin, (req, res) => {
|
||
const cats = persistedState.categories ?? [];
|
||
if (!cats.find(c => c.id === req.params.id)) { res.status(404).json({ error: 'Nicht gefunden' }); return; }
|
||
persistedState.categories = cats.filter(c => c.id !== req.params.id);
|
||
const fc = persistedState.fileCategories ?? {};
|
||
for (const k of Object.keys(fc)) fc[k] = (fc[k] ?? []).filter(x => x !== req.params.id);
|
||
writeState(); res.json({ ok: true });
|
||
});
|
||
app.post('/api/soundboard/categories/assign', requireAdmin, (req, res) => {
|
||
const { files, add, remove } = req.body ?? {};
|
||
if (!Array.isArray(files) || !files.length) { res.status(400).json({ error: 'files[] erforderlich' }); return; }
|
||
const validCats = new Set((persistedState.categories ?? []).map(c => c.id));
|
||
const fc = persistedState.fileCategories ?? {};
|
||
for (const rel of files) {
|
||
const old = new Set(fc[rel] ?? []);
|
||
for (const a of (add ?? []).filter((id: string) => validCats.has(id))) old.add(a);
|
||
for (const r of (remove ?? []).filter((id: string) => validCats.has(id))) old.delete(r);
|
||
fc[rel] = Array.from(old);
|
||
}
|
||
persistedState.fileCategories = fc; writeState(); res.json({ ok: true, fileCategories: fc });
|
||
});
|
||
|
||
// ── Badges ──
|
||
app.post('/api/soundboard/badges/assign', requireAdmin, (req, res) => {
|
||
const { files, add, remove } = req.body ?? {};
|
||
if (!Array.isArray(files) || !files.length) { res.status(400).json({ error: 'files[] erforderlich' }); return; }
|
||
const fb = persistedState.fileBadges ?? {};
|
||
for (const rel of files) { const old = new Set(fb[rel] ?? []);
|
||
for (const a of (add ?? [])) old.add(a); for (const r of (remove ?? [])) old.delete(r); fb[rel] = Array.from(old); }
|
||
persistedState.fileBadges = fb; writeState(); res.json({ ok: true, fileBadges: fb });
|
||
});
|
||
app.post('/api/soundboard/badges/clear', requireAdmin, (req, res) => {
|
||
const { files } = req.body ?? {};
|
||
if (!Array.isArray(files) || !files.length) { res.status(400).json({ error: 'files[] erforderlich' }); return; }
|
||
const fb = persistedState.fileBadges ?? {};
|
||
for (const rel of files) delete fb[rel];
|
||
persistedState.fileBadges = fb; writeState(); res.json({ ok: true, fileBadges: fb });
|
||
});
|
||
|
||
// ── Admin: Delete & Rename ──
|
||
app.post('/api/soundboard/admin/sounds/delete', requireAdmin, (req, res) => {
|
||
const { paths: pathsList } = req.body ?? {};
|
||
if (!Array.isArray(pathsList) || !pathsList.length) { res.status(400).json({ error: 'paths[] erforderlich' }); return; }
|
||
const results: any[] = [];
|
||
for (const rel of pathsList) {
|
||
const full = safeSoundsPath(rel);
|
||
if (!full) { results.push({ path: rel, ok: false, error: 'Ungültig' }); continue; }
|
||
try {
|
||
if (fs.existsSync(full) && fs.statSync(full).isFile()) {
|
||
fs.unlinkSync(full);
|
||
try { const cf = path.join(NORM_CACHE_DIR, normCacheKey(full)); if (fs.existsSync(cf)) fs.unlinkSync(cf); } catch {}
|
||
results.push({ path: rel, ok: true });
|
||
} else results.push({ path: rel, ok: false, error: 'nicht gefunden' });
|
||
} catch (e: any) { results.push({ path: rel, ok: false, error: e?.message }); }
|
||
}
|
||
res.json({ ok: true, results });
|
||
});
|
||
|
||
app.post('/api/soundboard/admin/sounds/rename', requireAdmin, (req, res) => {
|
||
const { from, to } = req.body ?? {};
|
||
if (!from || !to) { res.status(400).json({ error: 'from und to erforderlich' }); return; }
|
||
const src = safeSoundsPath(from);
|
||
if (!src) { res.status(400).json({ error: 'Ungültiger Quellpfad' }); return; }
|
||
const parsed = path.parse(from);
|
||
const sanitizedName = to.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
|
||
const dstRel = path.join(parsed.dir || '', `${sanitizedName}.mp3`);
|
||
const dst = safeSoundsPath(dstRel);
|
||
if (!dst) { res.status(400).json({ error: 'Ungültiger Zielpfad' }); return; }
|
||
if (!fs.existsSync(src)) { res.status(404).json({ error: 'Quelle nicht gefunden' }); return; }
|
||
if (fs.existsSync(dst)) { res.status(409).json({ error: 'Ziel existiert bereits' }); return; }
|
||
fs.renameSync(src, dst);
|
||
try { const cf = path.join(NORM_CACHE_DIR, normCacheKey(src)); if (fs.existsSync(cf)) fs.unlinkSync(cf); } catch {}
|
||
res.json({ ok: true, from, to: dstRel });
|
||
});
|
||
|
||
// ── Upload ──
|
||
const uploadStorage = multer.diskStorage({
|
||
destination: (_req: any, _file: any, cb: (e: null, dest: string) => void) => cb(null, SOUNDS_DIR),
|
||
filename: (_req: any, file: { originalname: string }, cb: (e: null, name: string) => void) => {
|
||
const safe = file.originalname.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
|
||
const { name, ext } = path.parse(safe);
|
||
let finalName = safe; let i = 2;
|
||
while (fs.existsSync(path.join(SOUNDS_DIR, finalName))) { finalName = `${name}-${i}${ext}`; i++; }
|
||
cb(null, finalName);
|
||
},
|
||
});
|
||
const uploadMulter = multer({ storage: uploadStorage,
|
||
fileFilter: (_req: any, file: { originalname: string }, cb: (e: null, ok: boolean) => void) => {
|
||
cb(null, /\.(mp3|wav)$/i.test(file.originalname));
|
||
}, limits: { fileSize: 50 * 1024 * 1024, files: 20 } });
|
||
|
||
app.post('/api/soundboard/upload', requireAdmin, (req, res) => {
|
||
uploadMulter.array('files', 20)(req, res, async (err: any) => {
|
||
if (err) { res.status(400).json({ error: err?.message ?? 'Upload fehlgeschlagen' }); return; }
|
||
const files = (req as any).files as any[] | undefined;
|
||
if (!files?.length) { res.status(400).json({ error: 'Keine gültigen Dateien' }); return; }
|
||
if (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;
|