feat: Soundboard Plugin + Radio Globe Fixes
- Add Soundboard plugin (full Jukebox port): server + frontend + CSS - Fix Radio Globe: swap geo coords (API returns [lng,lat] not [lat,lng]) - Fix Radio stations showing "Unbekannt": use item.page.title + fix channel ID regex - Add DirectMessages + MessageContent intents for DM commands - Register SoundboardTab in App.tsx with scoped theme/card-size CSS vars Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6696fed9d7
commit
901dfe54be
9 changed files with 4545 additions and 14 deletions
|
|
@ -8,17 +8,21 @@
|
|||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"discord.js": "^14.18.0",
|
||||
"@discordjs/voice": "^0.18.0",
|
||||
"@discordjs/opus": "^0.9.0",
|
||||
"sodium-native": "^4.3.1",
|
||||
"express": "^5.0.0",
|
||||
"discord.js": "^14.25.1",
|
||||
"@discordjs/voice": "^0.19.0",
|
||||
"@discordjs/opus": "^0.10.0",
|
||||
"sodium-native": "^5.0.10",
|
||||
"libsodium-wrappers": "^0.8.2",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"express": "^5.2.1",
|
||||
"multer": "^2.0.0",
|
||||
"prism-media": "^1.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.0",
|
||||
"typescript": "^5.9.3",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/multer": "^2.0.0",
|
||||
"tsx": "^4.19.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { Client, GatewayIntentBits } from 'discord.js';
|
||||
import { Client, GatewayIntentBits, Partials } from 'discord.js';
|
||||
|
||||
const client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildVoiceStates,
|
||||
GatewayIntentBits.DirectMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
],
|
||||
partials: [Partials.Channel],
|
||||
});
|
||||
|
||||
export default client;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { addSSEClient, removeSSEClient, sseBroadcast, getSSEClientCount } from '
|
|||
import { loadState, getFullState } from './core/persistence.js';
|
||||
import { getPlugins, registerPlugin, PluginContext } from './core/plugin.js';
|
||||
import radioPlugin from './plugins/radio/index.js';
|
||||
import soundboardPlugin from './plugins/soundboard/index.js';
|
||||
|
||||
// ── Config ──
|
||||
const PORT = Number(process.env.PORT ?? 8080);
|
||||
|
|
@ -101,6 +102,7 @@ client.once('ready', async () => {
|
|||
async function boot(): Promise<void> {
|
||||
// ── Register plugins ──
|
||||
registerPlugin(radioPlugin);
|
||||
registerPlugin(soundboardPlugin);
|
||||
|
||||
// Init all plugins
|
||||
for (const p of getPlugins()) {
|
||||
|
|
|
|||
|
|
@ -64,10 +64,10 @@ export async function fetchPlaceChannels(placeId: string): Promise<RadioChannel[
|
|||
const channels: RadioChannel[] = [];
|
||||
for (const section of data?.data?.content ?? []) {
|
||||
for (const item of section.items ?? []) {
|
||||
const href: string = item.href ?? item.page?.url ?? '';
|
||||
const match = href.match(/\/listen\/([^/]+)/);
|
||||
const href: string = item.page?.url ?? item.href ?? '';
|
||||
const match = href.match(/\/listen\/[^/]+\/([^/]+)/);
|
||||
if (match) {
|
||||
channels.push({ id: match[1], title: item.title ?? 'Unbekannt' });
|
||||
channels.push({ id: match[1], title: item.page?.title ?? item.title ?? 'Unbekannt' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
973
server/src/plugins/soundboard/index.ts
Normal file
973
server/src/plugins/soundboard/index.ts
Normal file
|
|
@ -0,0 +1,973 @@
|
|||
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,
|
||||
type VoiceConnection, type AudioResource,
|
||||
} from '@discordjs/voice';
|
||||
import { ChannelType, Events, type VoiceState, type Message } from 'discord.js';
|
||||
import type { Plugin, PluginContext } from '../../core/plugin.js';
|
||||
import { sseBroadcast } from '../../core/sse.js';
|
||||
|
||||
// ── Config (env) ──
|
||||
const SOUNDS_DIR = process.env.SOUNDS_DIR ?? '/data/sounds';
|
||||
const NORMALIZE_ENABLE = String(process.env.NORMALIZE_ENABLE ?? 'true').toLowerCase() !== 'false';
|
||||
const NORMALIZE_I = String(process.env.NORMALIZE_I ?? '-16');
|
||||
const NORMALIZE_LRA = String(process.env.NORMALIZE_LRA ?? '11');
|
||||
const NORMALIZE_TP = String(process.env.NORMALIZE_TP ?? '-1.5');
|
||||
const NORM_CONCURRENCY = Math.max(1, Number(process.env.NORM_CONCURRENCY ?? 2));
|
||||
const PCM_MEMORY_CACHE_MAX_MB = Number(process.env.PCM_CACHE_MAX_MB ?? '512');
|
||||
const PCM_PER_FILE_MAX_MB = 50;
|
||||
|
||||
// ── Types ──
|
||||
type Category = { id: string; name: string; color?: string; sort?: number };
|
||||
type PersistedState = {
|
||||
volumes: Record<string, number>;
|
||||
plays: Record<string, number>;
|
||||
totalPlays: number;
|
||||
categories?: Category[];
|
||||
fileCategories?: Record<string, string[]>;
|
||||
fileBadges?: Record<string, string[]>;
|
||||
selectedChannels?: Record<string, string>;
|
||||
entranceSounds?: Record<string, string>;
|
||||
exitSounds?: Record<string, string>;
|
||||
};
|
||||
type ListedSound = { fileName: string; name: string; folder: string; relativePath: string };
|
||||
type GuildAudioState = {
|
||||
connection: VoiceConnection;
|
||||
player: ReturnType<typeof createAudioPlayer>;
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
currentResource?: AudioResource;
|
||||
currentVolume: number;
|
||||
};
|
||||
|
||||
// ── Persisted State ──
|
||||
const STATE_FILE = path.join(SOUNDS_DIR, 'state.json');
|
||||
const STATE_FILE_OLD = path.join(path.resolve(SOUNDS_DIR, '..'), 'state.json');
|
||||
|
||||
function readPersistedState(): PersistedState {
|
||||
try {
|
||||
if (fs.existsSync(STATE_FILE)) {
|
||||
const p = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
||||
return { volumes: p.volumes ?? {}, plays: p.plays ?? {}, totalPlays: p.totalPlays ?? 0,
|
||||
categories: Array.isArray(p.categories) ? p.categories : [], fileCategories: p.fileCategories ?? {},
|
||||
fileBadges: p.fileBadges ?? {}, selectedChannels: p.selectedChannels ?? {},
|
||||
entranceSounds: p.entranceSounds ?? {}, exitSounds: p.exitSounds ?? {} };
|
||||
}
|
||||
if (fs.existsSync(STATE_FILE_OLD)) {
|
||||
const p = JSON.parse(fs.readFileSync(STATE_FILE_OLD, 'utf8'));
|
||||
const m: PersistedState = { volumes: p.volumes ?? {}, plays: p.plays ?? {}, totalPlays: p.totalPlays ?? 0,
|
||||
categories: Array.isArray(p.categories) ? p.categories : [], fileCategories: p.fileCategories ?? {},
|
||||
fileBadges: p.fileBadges ?? {}, selectedChannels: p.selectedChannels ?? {},
|
||||
entranceSounds: p.entranceSounds ?? {}, exitSounds: p.exitSounds ?? {} };
|
||||
try { fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true }); fs.writeFileSync(STATE_FILE, JSON.stringify(m, null, 2), 'utf8'); } catch {}
|
||||
return m;
|
||||
}
|
||||
} catch {}
|
||||
return { volumes: {}, plays: {}, totalPlays: 0 };
|
||||
}
|
||||
|
||||
let persistedState: PersistedState;
|
||||
let _writeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function writeState(): void {
|
||||
try { fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true }); fs.writeFileSync(STATE_FILE, JSON.stringify(persistedState, null, 2), 'utf8'); } catch (e) { console.warn('[Soundboard] state write error:', e); }
|
||||
}
|
||||
function writeStateDebounced(): void {
|
||||
if (_writeTimer) return;
|
||||
_writeTimer = setTimeout(() => { _writeTimer = null; writeState(); }, 2000);
|
||||
}
|
||||
|
||||
function getPersistedVolume(guildId: string): number {
|
||||
const v = persistedState.volumes[guildId];
|
||||
return typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 1;
|
||||
}
|
||||
|
||||
function safeSoundsPath(rel: string): string | null {
|
||||
const resolved = path.resolve(SOUNDS_DIR, rel);
|
||||
if (!resolved.startsWith(path.resolve(SOUNDS_DIR) + path.sep) && resolved !== path.resolve(SOUNDS_DIR)) return null;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function incrementPlaysFor(relativePath: string) {
|
||||
try { const key = relativePath.replace(/\\/g, '/');
|
||||
persistedState.plays[key] = (persistedState.plays[key] ?? 0) + 1;
|
||||
persistedState.totalPlays = (persistedState.totalPlays ?? 0) + 1;
|
||||
writeStateDebounced();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── Loudnorm Cache ──
|
||||
const NORM_CACHE_DIR = path.join(SOUNDS_DIR, '.norm-cache');
|
||||
|
||||
function normCacheKey(filePath: string): string {
|
||||
const rel = path.relative(SOUNDS_DIR, filePath).replace(/\\/g, '/');
|
||||
return rel.replace(/[/\\:*?"<>|]/g, '_') + '.pcm';
|
||||
}
|
||||
|
||||
function getNormCachePath(filePath: string): string | null {
|
||||
const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath));
|
||||
if (!fs.existsSync(cacheFile)) return null;
|
||||
try {
|
||||
const srcMtime = fs.statSync(filePath).mtimeMs;
|
||||
const cacheMtime = fs.statSync(cacheFile).mtimeMs;
|
||||
if (srcMtime > cacheMtime) { try { fs.unlinkSync(cacheFile); } catch {} invalidatePcmMemory(cacheFile); return null; }
|
||||
} catch { return null; }
|
||||
return cacheFile;
|
||||
}
|
||||
|
||||
function normalizeToCache(filePath: string): Promise<string> {
|
||||
const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath));
|
||||
return new Promise((resolve, reject) => {
|
||||
const ff = child_process.spawn('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', '-i', filePath,
|
||||
'-af', `loudnorm=I=${NORMALIZE_I}:LRA=${NORMALIZE_LRA}:TP=${NORMALIZE_TP}`,
|
||||
'-f', 's16le', '-ar', '48000', '-ac', '2', cacheFile]);
|
||||
ff.on('error', reject);
|
||||
ff.on('close', (code) => { if (code === 0) resolve(cacheFile); else reject(new Error(`ffmpeg exit ${code}`)); });
|
||||
});
|
||||
}
|
||||
|
||||
// ── PCM Memory Cache ──
|
||||
const pcmMemoryCache = new Map<string, Buffer>();
|
||||
let pcmMemoryCacheBytes = 0;
|
||||
|
||||
function getPcmFromMemory(cachedPath: string): Buffer | null {
|
||||
const buf = pcmMemoryCache.get(cachedPath);
|
||||
if (buf) return buf;
|
||||
try {
|
||||
const stat = fs.statSync(cachedPath);
|
||||
if (stat.size > PCM_PER_FILE_MAX_MB * 1024 * 1024) return null;
|
||||
if (pcmMemoryCacheBytes + stat.size > PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) return null;
|
||||
const data = fs.readFileSync(cachedPath);
|
||||
pcmMemoryCache.set(cachedPath, data);
|
||||
pcmMemoryCacheBytes += data.byteLength;
|
||||
return data;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function invalidatePcmMemory(cachedPath: string): void {
|
||||
const buf = pcmMemoryCache.get(cachedPath);
|
||||
if (buf) { pcmMemoryCacheBytes -= buf.byteLength; pcmMemoryCache.delete(cachedPath); }
|
||||
}
|
||||
|
||||
// ── Sound listing ──
|
||||
function listAllSounds(): ListedSound[] {
|
||||
const rootEntries = fs.readdirSync(SOUNDS_DIR, { withFileTypes: true });
|
||||
const rootFiles: ListedSound[] = rootEntries
|
||||
.filter(d => d.isFile() && /\.(mp3|wav)$/i.test(d.name))
|
||||
.map(d => ({ fileName: d.name, name: path.parse(d.name).name, folder: '', relativePath: d.name }));
|
||||
|
||||
const folderItems: ListedSound[] = [];
|
||||
for (const dirent of rootEntries.filter(d => d.isDirectory() && d.name !== '.norm-cache')) {
|
||||
const folderPath = path.join(SOUNDS_DIR, dirent.name);
|
||||
for (const e of fs.readdirSync(folderPath, { withFileTypes: true })) {
|
||||
if (!e.isFile() || !/\.(mp3|wav)$/i.test(e.name)) continue;
|
||||
folderItems.push({ fileName: e.name, name: path.parse(e.name).name, folder: dirent.name,
|
||||
relativePath: path.join(dirent.name, e.name) });
|
||||
}
|
||||
}
|
||||
return [...rootFiles, ...folderItems].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// ── Norm cache sync ──
|
||||
async function syncNormCache(): Promise<void> {
|
||||
if (!NORMALIZE_ENABLE) return;
|
||||
const t0 = Date.now();
|
||||
const allSounds = listAllSounds();
|
||||
const expectedKeys = new Set<string>();
|
||||
const toProcess: string[] = [];
|
||||
|
||||
for (const s of allSounds) {
|
||||
const fp = path.join(SOUNDS_DIR, s.relativePath);
|
||||
const key = normCacheKey(fp);
|
||||
expectedKeys.add(key);
|
||||
if (!fs.existsSync(fp)) continue;
|
||||
if (getNormCachePath(fp)) continue;
|
||||
toProcess.push(fp);
|
||||
}
|
||||
|
||||
let created = 0, failed = 0;
|
||||
const skipped = allSounds.length - toProcess.length;
|
||||
const queue = [...toProcess];
|
||||
async function worker(): Promise<void> {
|
||||
while (queue.length > 0) {
|
||||
const fp = queue.shift()!;
|
||||
try { await normalizeToCache(fp); created++; }
|
||||
catch (e) { failed++; console.warn(`[Soundboard] norm-cache failed: ${path.relative(SOUNDS_DIR, fp)}`, e); }
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(NORM_CONCURRENCY, toProcess.length || 1) }, worker));
|
||||
|
||||
let cleaned = 0;
|
||||
try {
|
||||
for (const f of fs.readdirSync(NORM_CACHE_DIR)) {
|
||||
if (!expectedKeys.has(f)) { try { fs.unlinkSync(path.join(NORM_CACHE_DIR, f)); cleaned++; } catch {} }
|
||||
}
|
||||
} catch {}
|
||||
|
||||
console.log(`[Soundboard] Norm-cache sync (${((Date.now() - t0) / 1000).toFixed(1)}s): ${created} new, ${skipped} cached, ${failed} failed, ${cleaned} orphans`);
|
||||
}
|
||||
|
||||
// ── Audio State ──
|
||||
const guildAudioState = new Map<string, GuildAudioState>();
|
||||
const partyTimers = new Map<string, NodeJS.Timeout>();
|
||||
const partyActive = new Set<string>();
|
||||
const nowPlaying = new Map<string, string>();
|
||||
const connectedSince = new Map<string, string>();
|
||||
|
||||
// ── Voice Lifecycle ──
|
||||
async function ensureConnectionReady(connection: VoiceConnection, channelId: string, guildId: string, guild: any): Promise<VoiceConnection> {
|
||||
try { await entersState(connection, VoiceConnectionStatus.Ready, 15_000); return connection; } catch {}
|
||||
try { connection.rejoin({ channelId, selfDeaf: false, selfMute: false }); await entersState(connection, VoiceConnectionStatus.Ready, 15_000); return connection; } catch {}
|
||||
try { connection.destroy(); } catch {}
|
||||
guildAudioState.delete(guildId);
|
||||
const newConn = joinVoiceChannel({ channelId, guildId, adapterCreator: guild.voiceAdapterCreator as any, selfMute: false, selfDeaf: false });
|
||||
try { await entersState(newConn, VoiceConnectionStatus.Ready, 15_000); return newConn; }
|
||||
catch { 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) => {
|
||||
if (newS.status === VoiceConnectionStatus.Ready) {
|
||||
reconnectAttempts = 0; isReconnecting = false;
|
||||
if (!connectedSince.has(state.guildId)) connectedSince.set(state.guildId, new Date().toISOString());
|
||||
return;
|
||||
}
|
||||
if (isReconnecting) return;
|
||||
try {
|
||||
if (newS.status === VoiceConnectionStatus.Disconnected) {
|
||||
try { await Promise.race([entersState(connection, VoiceConnectionStatus.Signalling, 5_000), entersState(connection, VoiceConnectionStatus.Connecting, 5_000)]); }
|
||||
catch {
|
||||
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { reconnectAttempts++; connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); }
|
||||
else { reconnectAttempts = 0; try { connection.destroy(); } catch {}
|
||||
const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: guild.voiceAdapterCreator as any, selfMute: false, selfDeaf: false });
|
||||
state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild); }
|
||||
}
|
||||
} else if (newS.status === VoiceConnectionStatus.Destroyed) {
|
||||
connectedSince.delete(state.guildId);
|
||||
const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: guild.voiceAdapterCreator as any, selfMute: false, selfDeaf: false });
|
||||
state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild);
|
||||
} else if (newS.status === VoiceConnectionStatus.Connecting || newS.status === VoiceConnectionStatus.Signalling) {
|
||||
isReconnecting = true;
|
||||
try { await entersState(connection, VoiceConnectionStatus.Ready, 15_000); }
|
||||
catch {
|
||||
reconnectAttempts++;
|
||||
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; try { connection.destroy(); } catch {}
|
||||
const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: guild.voiceAdapterCreator as any, selfMute: false, selfDeaf: false });
|
||||
state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild); }
|
||||
}
|
||||
}
|
||||
} catch { isReconnecting = false; }
|
||||
});
|
||||
(connection as any).__lifecycleAttached = true;
|
||||
}
|
||||
|
||||
// ── Playback ──
|
||||
let _pluginCtx: PluginContext | null = null;
|
||||
|
||||
async function playFilePath(guildId: string, channelId: string, filePath: string, volume?: number, relativeKey?: string): Promise<void> {
|
||||
const ctx = _pluginCtx!;
|
||||
const guild = ctx.client.guilds.cache.get(guildId);
|
||||
if (!guild) throw new Error('Guild nicht gefunden');
|
||||
|
||||
let state = guildAudioState.get(guildId);
|
||||
if (!state) {
|
||||
const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: guild.voiceAdapterCreator as any, selfMute: false, selfDeaf: false });
|
||||
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);
|
||||
}
|
||||
|
||||
// Channel-Wechsel
|
||||
try {
|
||||
const current = getVoiceConnection(guildId);
|
||||
if (current && current.joinConfig?.channelId !== channelId) {
|
||||
current.destroy();
|
||||
const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: guild.voiceAdapterCreator as any, selfMute: false, selfDeaf: false });
|
||||
const player = state.player ?? createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } });
|
||||
connection.subscribe(player);
|
||||
state = { connection, player, guildId, channelId, currentVolume: state.currentVolume ?? getPersistedVolume(guildId) };
|
||||
guildAudioState.set(guildId, state);
|
||||
state.connection = await ensureConnectionReady(connection, channelId, guildId, guild);
|
||||
attachVoiceLifecycle(state, guild);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (!getVoiceConnection(guildId)) {
|
||||
const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: guild.voiceAdapterCreator as any, selfMute: false, selfDeaf: false });
|
||||
const player = state?.player ?? createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } });
|
||||
connection.subscribe(player);
|
||||
state = { connection, player, guildId, channelId, currentVolume: state?.currentVolume ?? getPersistedVolume(guildId) };
|
||||
guildAudioState.set(guildId, state);
|
||||
state.connection = await ensureConnectionReady(connection, channelId, guildId, guild);
|
||||
attachVoiceLifecycle(state, guild);
|
||||
}
|
||||
|
||||
const useVolume = typeof volume === 'number' && Number.isFinite(volume)
|
||||
? Math.max(0, Math.min(1, volume))
|
||||
: (state.currentVolume ?? 1);
|
||||
|
||||
let resource: AudioResource;
|
||||
if (NORMALIZE_ENABLE) {
|
||||
const cachedPath = getNormCachePath(filePath);
|
||||
if (cachedPath) {
|
||||
const pcmBuf = getPcmFromMemory(cachedPath);
|
||||
if (pcmBuf) {
|
||||
resource = createAudioResource(Readable.from(pcmBuf), { inlineVolume: useVolume !== 1, inputType: StreamType.Raw });
|
||||
} else {
|
||||
resource = createAudioResource(fs.createReadStream(cachedPath, { highWaterMark: 256 * 1024 }), { inlineVolume: true, inputType: StreamType.Raw });
|
||||
}
|
||||
} else {
|
||||
const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath));
|
||||
const ff = child_process.spawn('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-i', filePath,
|
||||
'-af', `loudnorm=I=${NORMALIZE_I}:LRA=${NORMALIZE_LRA}:TP=${NORMALIZE_TP}`,
|
||||
'-f', 's16le', '-ar', '48000', '-ac', '2', 'pipe:1']);
|
||||
const playerStream = new PassThrough();
|
||||
const cacheWrite = fs.createWriteStream(cacheFile);
|
||||
ff.stdout.on('data', (chunk: Buffer) => { playerStream.write(chunk); cacheWrite.write(chunk); });
|
||||
ff.stdout.on('end', () => {
|
||||
playerStream.end(); cacheWrite.end();
|
||||
try { const buf = fs.readFileSync(cacheFile);
|
||||
if (pcmMemoryCacheBytes + buf.byteLength <= PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) { pcmMemoryCache.set(cacheFile, buf); pcmMemoryCacheBytes += buf.byteLength; }
|
||||
} catch {}
|
||||
});
|
||||
ff.on('error', () => { try { fs.unlinkSync(cacheFile); } catch {} });
|
||||
ff.on('close', (code) => { if (code !== 0) { try { fs.unlinkSync(cacheFile); } catch {} } });
|
||||
resource = createAudioResource(playerStream, { inlineVolume: true, inputType: StreamType.Raw });
|
||||
}
|
||||
} else {
|
||||
resource = createAudioResource(filePath, { inlineVolume: true });
|
||||
}
|
||||
|
||||
if (resource.volume) resource.volume.setVolume(useVolume);
|
||||
state.player.stop();
|
||||
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);
|
||||
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();
|
||||
console.log(`[Soundboard] ${listAllSounds().length} sounds, ${persistedState.totalPlays ?? 0} total plays`);
|
||||
},
|
||||
|
||||
async onReady(ctx) {
|
||||
// Entrance/Exit Sounds
|
||||
ctx.client.on(Events.VoiceStateUpdate, async (oldState: VoiceState, newState: VoiceState) => {
|
||||
try {
|
||||
const userId = (newState.id || oldState.id) as string;
|
||||
if (!userId || userId === ctx.client.user?.id) return;
|
||||
const guildId = (newState.guild?.id || oldState.guild?.id) as string;
|
||||
if (!guildId) return;
|
||||
const before = oldState.channelId;
|
||||
const after = newState.channelId;
|
||||
|
||||
if (after && before !== after) {
|
||||
const file = persistedState.entranceSounds?.[userId];
|
||||
if (file) {
|
||||
const abs = path.join(SOUNDS_DIR, file.replace(/\\/g, '/'));
|
||||
if (fs.existsSync(abs)) { try { await playFilePath(guildId, after, abs, undefined, file); } catch {} }
|
||||
}
|
||||
}
|
||||
if (before && !after) {
|
||||
const file = persistedState.exitSounds?.[userId];
|
||||
if (file) {
|
||||
const abs = path.join(SOUNDS_DIR, file.replace(/\\/g, '/'));
|
||||
if (fs.existsSync(abs)) { try { await playFilePath(guildId, before, abs, undefined, file); } catch {} }
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// DM Commands
|
||||
ctx.client.on(Events.MessageCreate, async (message: Message) => {
|
||||
try {
|
||||
if (message.author?.bot) return;
|
||||
const content = (message.content || '').trim();
|
||||
if (content.startsWith('?')) { await handleCommand(message, content); return; }
|
||||
if (!message.channel?.isDMBased?.()) return;
|
||||
if (message.attachments.size === 0) return;
|
||||
for (const [, attachment] of message.attachments) {
|
||||
const name = attachment.name ?? 'upload';
|
||||
if (!/\.(mp3|wav)$/i.test(name)) continue;
|
||||
const safeName = name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
|
||||
let targetPath = path.join(SOUNDS_DIR, safeName);
|
||||
let i = 2;
|
||||
while (fs.existsSync(targetPath)) { const { name: n, ext } = path.parse(safeName); targetPath = path.join(SOUNDS_DIR, `${n}-${i}${ext}`); i++; }
|
||||
const res = await fetch(attachment.url);
|
||||
if (!res.ok) continue;
|
||||
fs.writeFileSync(targetPath, Buffer.from(await res.arrayBuffer()));
|
||||
if (NORMALIZE_ENABLE) normalizeToCache(targetPath).catch(() => {});
|
||||
await message.author.send?.(`Sound gespeichert: ${path.basename(targetPath)}`);
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// Norm-Cache Sync
|
||||
syncNormCache();
|
||||
|
||||
// Voice stats broadcast
|
||||
setInterval(() => {
|
||||
if (guildAudioState.size === 0) return;
|
||||
for (const [gId, st] of guildAudioState) {
|
||||
const status = st.connection.state?.status ?? 'unknown';
|
||||
if (status === 'ready' && !connectedSince.has(gId)) connectedSince.set(gId, new Date().toISOString());
|
||||
const ch = ctx.client.channels.cache.get(st.channelId);
|
||||
sseBroadcast({ type: 'soundboard_voicestats', plugin: 'soundboard', guildId: gId,
|
||||
voicePing: (st.connection.ping as any)?.ws ?? null, gatewayPing: ctx.client.ws.ping,
|
||||
status, channelName: ch && 'name' in ch ? (ch as any).name : null,
|
||||
connectedSince: connectedSince.get(gId) ?? null });
|
||||
}
|
||||
}, 5_000);
|
||||
},
|
||||
|
||||
registerRoutes(app: express.Application, ctx: PluginContext) {
|
||||
const requireAdmin = (req: express.Request, res: express.Response, next: () => void) => {
|
||||
if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; }
|
||||
if (!verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin'))) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
|
||||
next();
|
||||
};
|
||||
|
||||
// ── Admin Auth ──
|
||||
app.post('/api/soundboard/admin/login', (req, res) => {
|
||||
if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; }
|
||||
const { password } = req.body ?? {};
|
||||
if (!password || password !== ctx.adminPwd) { res.status(401).json({ error: 'Falsches Passwort' }); return; }
|
||||
const token = signAdminToken(ctx.adminPwd, { iat: Date.now(), exp: Date.now() + 7 * 24 * 3600 * 1000 });
|
||||
res.setHeader('Set-Cookie', `admin=${encodeURIComponent(token)}; HttpOnly; Path=/; Max-Age=${7 * 24 * 3600}; SameSite=Lax`);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.post('/api/soundboard/admin/logout', (_req, res) => {
|
||||
res.setHeader('Set-Cookie', 'admin=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.get('/api/soundboard/admin/status', (req, res) => {
|
||||
res.json({ authenticated: verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin')) });
|
||||
});
|
||||
|
||||
// ── Sounds ──
|
||||
app.get('/api/soundboard/sounds', (req, res) => {
|
||||
const q = String(req.query.q ?? '').toLowerCase();
|
||||
const folderFilter = typeof req.query.folder === 'string' ? req.query.folder : '__all__';
|
||||
const categoryFilter = typeof req.query.categoryId === 'string' ? String(req.query.categoryId) : undefined;
|
||||
const useFuzzy = String(req.query.fuzzy ?? '0') === '1';
|
||||
|
||||
const allItems = listAllSounds();
|
||||
const folderCounts = new Map<string, number>();
|
||||
for (const it of allItems) { if (it.folder) folderCounts.set(it.folder, (folderCounts.get(it.folder) ?? 0) + 1); }
|
||||
const folders: Array<{ key: string; name: string; count: number }> = [];
|
||||
for (const [key, count] of folderCounts) folders.push({ key, name: key, count });
|
||||
|
||||
const allWithTime = allItems.map(it => {
|
||||
try { return { ...it, mtimeMs: fs.statSync(path.join(SOUNDS_DIR, it.relativePath)).mtimeMs }; }
|
||||
catch { return { ...it, mtimeMs: 0 }; }
|
||||
});
|
||||
const sortedByNewest = [...allWithTime].sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
const recentTop10 = sortedByNewest.slice(0, 10);
|
||||
const recentTop5Set = new Set(recentTop10.slice(0, 5).map(x => x.relativePath));
|
||||
|
||||
let itemsByFolder = allItems as ListedSound[];
|
||||
if (folderFilter !== '__all__') {
|
||||
if (folderFilter === '__recent__') itemsByFolder = recentTop10;
|
||||
else itemsByFolder = allItems.filter(it => folderFilter === '' ? it.folder === '' : it.folder === folderFilter);
|
||||
}
|
||||
|
||||
function fuzzyScore(text: string, pattern: string): number {
|
||||
if (!pattern) return 1;
|
||||
if (text === pattern) return 2000;
|
||||
const idx = text.indexOf(pattern);
|
||||
if (idx !== -1) return 1000 + (idx === 0 ? 200 : 0) - idx * 2;
|
||||
let tI = 0, pI = 0, score = 0, last = -1, gaps = 0, first = -1;
|
||||
while (tI < text.length && pI < pattern.length) {
|
||||
if (text[tI] === pattern[pI]) { if (first === -1) first = tI; if (last === tI - 1) score += 5; last = tI; pI++; }
|
||||
else if (first !== -1) gaps++;
|
||||
tI++;
|
||||
}
|
||||
if (pI !== pattern.length) return 0;
|
||||
return score + Math.max(0, 300 - first * 2) + Math.max(0, 100 - gaps * 10);
|
||||
}
|
||||
|
||||
let filteredItems = itemsByFolder;
|
||||
if (q) {
|
||||
if (useFuzzy) {
|
||||
filteredItems = itemsByFolder.map(it => ({ it, score: fuzzyScore(it.name.toLowerCase(), q) }))
|
||||
.filter(x => x.score > 0).sort((a, b) => b.score - a.score || a.it.name.localeCompare(b.it.name))
|
||||
.map(x => x.it);
|
||||
} else {
|
||||
filteredItems = itemsByFolder.filter(s => s.name.toLowerCase().includes(q));
|
||||
}
|
||||
}
|
||||
|
||||
const playsEntries = Object.entries(persistedState.plays || {});
|
||||
const top3 = playsEntries.sort((a, b) => (b[1] as number) - (a[1] as number)).slice(0, 3)
|
||||
.map(([rel, count]) => { const it = allItems.find(i => i.relativePath === rel || i.fileName === rel);
|
||||
return it ? { key: `__top__:${rel}`, name: `${it.name} (${count})`, count: 1 } : null; })
|
||||
.filter(Boolean) as Array<{ key: string; name: string; count: number }>;
|
||||
|
||||
const foldersOut = [
|
||||
{ key: '__all__', name: 'Alle', count: allItems.length },
|
||||
{ key: '__recent__', name: 'Neu', count: Math.min(10, allItems.length) },
|
||||
...(top3.length ? [{ key: '__top3__', name: 'Most Played (3)', count: top3.length }] : []),
|
||||
...folders
|
||||
];
|
||||
|
||||
let result = filteredItems;
|
||||
if (categoryFilter) {
|
||||
const fc = persistedState.fileCategories ?? {};
|
||||
result = result.filter(it => (fc[it.relativePath ?? it.fileName] ?? []).includes(categoryFilter));
|
||||
}
|
||||
if (folderFilter === '__top3__') {
|
||||
const keys = new Set(top3.map(t => t.key.split(':')[1]));
|
||||
result = allItems.filter(i => keys.has(i.relativePath ?? i.fileName));
|
||||
}
|
||||
|
||||
const top3Set = new Set(top3.map(t => t.key.split(':')[1]));
|
||||
const customBadges = persistedState.fileBadges ?? {};
|
||||
const withBadges = result.map(it => {
|
||||
const key = it.relativePath ?? it.fileName;
|
||||
const badges: string[] = [];
|
||||
if (recentTop5Set.has(key)) badges.push('new');
|
||||
if (top3Set.has(key)) badges.push('rocket');
|
||||
for (const b of (customBadges[key] ?? [])) badges.push(b);
|
||||
return { ...it, isRecent: recentTop5Set.has(key), badges };
|
||||
});
|
||||
|
||||
res.json({ items: withBadges, total: allItems.length, folders: foldersOut,
|
||||
categories: persistedState.categories ?? [], fileCategories: persistedState.fileCategories ?? {} });
|
||||
});
|
||||
|
||||
// ── Analytics ──
|
||||
app.get('/api/soundboard/analytics', (_req, res) => {
|
||||
const allItems = listAllSounds();
|
||||
const byKey = new Map<string, ListedSound>();
|
||||
for (const it of allItems) { byKey.set(it.relativePath, it); if (!byKey.has(it.fileName)) byKey.set(it.fileName, it); }
|
||||
const mostPlayed = Object.entries(persistedState.plays ?? {})
|
||||
.map(([rel, count]) => { const it = byKey.get(rel); return it ? { name: it.name, relativePath: it.relativePath, count: Number(count) || 0 } : null; })
|
||||
.filter((x): x is { name: string; relativePath: string; count: number } => !!x)
|
||||
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)).slice(0, 10);
|
||||
res.json({ totalSounds: allItems.length, totalPlays: persistedState.totalPlays ?? 0, mostPlayed });
|
||||
});
|
||||
|
||||
// ── Channels ──
|
||||
app.get('/api/soundboard/channels', (_req, res) => {
|
||||
if (!ctx.client.isReady()) { res.status(503).json({ error: 'Bot noch nicht bereit' }); return; }
|
||||
const allowed = new Set(ctx.allowedGuildIds);
|
||||
const result: any[] = [];
|
||||
for (const [, guild] of ctx.client.guilds.cache) {
|
||||
if (allowed.size > 0 && !allowed.has(guild.id)) continue;
|
||||
for (const [, ch] of guild.channels.cache) {
|
||||
if (ch?.type === ChannelType.GuildVoice || ch?.type === ChannelType.GuildStageVoice) {
|
||||
const sel = persistedState.selectedChannels?.[guild.id];
|
||||
result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name, selected: sel === ch.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
result.sort((a: any, b: any) => a.guildName.localeCompare(b.guildName) || a.channelName.localeCompare(b.channelName));
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
app.get('/api/soundboard/selected-channels', (_req, res) => {
|
||||
res.json({ selected: persistedState.selectedChannels ?? {} });
|
||||
});
|
||||
|
||||
app.post('/api/soundboard/selected-channel', async (req, res) => {
|
||||
const { guildId, channelId } = req.body ?? {};
|
||||
if (!guildId || !channelId) { res.status(400).json({ error: 'guildId und channelId erforderlich' }); return; }
|
||||
const guild = ctx.client.guilds.cache.get(guildId);
|
||||
if (!guild) { res.status(404).json({ error: 'Guild nicht gefunden' }); return; }
|
||||
const ch = guild.channels.cache.get(channelId);
|
||||
if (!ch || (ch.type !== ChannelType.GuildVoice && ch.type !== ChannelType.GuildStageVoice)) { res.status(400).json({ error: 'Ungültiger Voice-Channel' }); return; }
|
||||
if (!persistedState.selectedChannels) persistedState.selectedChannels = {};
|
||||
persistedState.selectedChannels[guildId] = channelId;
|
||||
writeState();
|
||||
sseBroadcast({ type: 'soundboard_channel', plugin: 'soundboard', guildId, channelId });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Play ──
|
||||
app.post('/api/soundboard/play', async (req, res) => {
|
||||
try {
|
||||
const { soundName, guildId, channelId, volume, folder, relativePath } = req.body ?? {};
|
||||
if (!soundName || !guildId || !channelId) { 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)) { res.status(404).json({ error: 'Sound nicht gefunden' }); return; }
|
||||
const relKey = relativePath || (folder ? `${folder}/${soundName}` : soundName);
|
||||
await playFilePath(guildId, channelId, filePath, volume, relKey);
|
||||
res.json({ ok: true });
|
||||
} catch (e: any) { res.status(500).json({ error: e?.message ?? 'Fehler' }); }
|
||||
});
|
||||
|
||||
app.post('/api/soundboard/play-url', async (req, res) => {
|
||||
try {
|
||||
const { url, guildId, channelId, volume } = req.body ?? {};
|
||||
if (!url || !guildId || !channelId) { res.status(400).json({ error: 'url, guildId, channelId erforderlich' }); return; }
|
||||
let parsed: URL;
|
||||
try { parsed = new URL(url); } catch { res.status(400).json({ error: 'Ungültige URL' }); return; }
|
||||
if (!parsed.pathname.toLowerCase().endsWith('.mp3')) { res.status(400).json({ error: 'Nur MP3-Links' }); return; }
|
||||
const dest = path.join(SOUNDS_DIR, path.basename(parsed.pathname));
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) { res.status(400).json({ error: 'Download fehlgeschlagen' }); return; }
|
||||
fs.writeFileSync(dest, Buffer.from(await r.arrayBuffer()));
|
||||
if (NORMALIZE_ENABLE) { try { await normalizeToCache(dest); } catch {} }
|
||||
try { await playFilePath(guildId, channelId, dest, volume, path.basename(dest)); }
|
||||
catch { res.status(500).json({ error: 'Abspielen fehlgeschlagen' }); return; }
|
||||
res.json({ ok: true, saved: path.basename(dest) });
|
||||
} catch (e: any) { res.status(500).json({ error: e?.message ?? 'Fehler' }); }
|
||||
});
|
||||
|
||||
// ── Volume ──
|
||||
app.post('/api/soundboard/volume', (req, res) => {
|
||||
const { guildId, volume } = req.body ?? {};
|
||||
if (!guildId || typeof volume !== 'number') { res.status(400).json({ error: 'guildId und volume erforderlich' }); return; }
|
||||
const safeVol = Math.max(0, Math.min(1, volume));
|
||||
const state = guildAudioState.get(guildId);
|
||||
if (state) {
|
||||
state.currentVolume = safeVol;
|
||||
if (state.currentResource?.volume) state.currentResource.volume.setVolume(safeVol);
|
||||
}
|
||||
persistedState.volumes[guildId] = safeVol;
|
||||
writeState();
|
||||
sseBroadcast({ type: 'soundboard_volume', plugin: 'soundboard', guildId, volume: safeVol });
|
||||
res.json({ ok: true, volume: safeVol });
|
||||
});
|
||||
|
||||
app.get('/api/soundboard/volume', (req, res) => {
|
||||
const guildId = String(req.query.guildId ?? '');
|
||||
if (!guildId) { res.status(400).json({ error: 'guildId erforderlich' }); return; }
|
||||
const state = guildAudioState.get(guildId);
|
||||
res.json({ volume: state?.currentVolume ?? getPersistedVolume(guildId) });
|
||||
});
|
||||
|
||||
// ── Stop ──
|
||||
app.post('/api/soundboard/stop', (req, res) => {
|
||||
const guildId = String((req.query.guildId || req.body?.guildId) ?? '');
|
||||
if (!guildId) { res.status(400).json({ error: 'guildId erforderlich' }); return; }
|
||||
const state = guildAudioState.get(guildId);
|
||||
if (!state) { res.status(404).json({ error: 'Kein aktiver Player' }); return; }
|
||||
state.player.stop(true);
|
||||
nowPlaying.delete(guildId);
|
||||
sseBroadcast({ type: 'soundboard_nowplaying', plugin: 'soundboard', guildId, name: '' });
|
||||
const t = partyTimers.get(guildId); if (t) clearTimeout(t);
|
||||
partyTimers.delete(guildId); partyActive.delete(guildId);
|
||||
sseBroadcast({ type: 'soundboard_party', plugin: 'soundboard', guildId, active: false });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Party ──
|
||||
app.post('/api/soundboard/party/start', (req, res) => {
|
||||
const { guildId, channelId } = req.body ?? {};
|
||||
if (!guildId || !channelId) { res.status(400).json({ error: 'guildId und channelId erforderlich' }); return; }
|
||||
const old = partyTimers.get(guildId); if (old) clearTimeout(old); partyTimers.delete(guildId);
|
||||
schedulePartyPlayback(guildId, channelId);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.post('/api/soundboard/party/stop', (req, res) => {
|
||||
const guildId = String(req.body?.guildId ?? '');
|
||||
if (!guildId) { res.status(400).json({ error: 'guildId erforderlich' }); return; }
|
||||
const t = partyTimers.get(guildId); if (t) clearTimeout(t);
|
||||
partyTimers.delete(guildId); partyActive.delete(guildId);
|
||||
sseBroadcast({ type: 'soundboard_party', plugin: 'soundboard', guildId, active: false });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Categories ──
|
||||
app.get('/api/soundboard/categories', (_req, res) => { res.json({ categories: persistedState.categories ?? [] }); });
|
||||
app.post('/api/soundboard/categories', requireAdmin, (req, res) => {
|
||||
const { name, color, sort } = req.body ?? {};
|
||||
if (!name?.trim()) { res.status(400).json({ error: 'name erforderlich' }); return; }
|
||||
const cat = { id: crypto.randomUUID(), name: name.trim(), color, sort };
|
||||
persistedState.categories = [...(persistedState.categories ?? []), cat];
|
||||
writeState(); res.json({ ok: true, category: cat });
|
||||
});
|
||||
app.patch('/api/soundboard/categories/:id', requireAdmin, (req, res) => {
|
||||
const cats = persistedState.categories ?? [];
|
||||
const idx = cats.findIndex(c => c.id === req.params.id);
|
||||
if (idx === -1) { res.status(404).json({ error: 'Nicht gefunden' }); return; }
|
||||
const { name, color, sort } = req.body ?? {};
|
||||
if (typeof name === 'string') cats[idx].name = name;
|
||||
if (typeof color === 'string') cats[idx].color = color;
|
||||
if (typeof sort === 'number') cats[idx].sort = sort;
|
||||
writeState(); res.json({ ok: true, category: cats[idx] });
|
||||
});
|
||||
app.delete('/api/soundboard/categories/:id', requireAdmin, (req, res) => {
|
||||
const cats = persistedState.categories ?? [];
|
||||
if (!cats.find(c => c.id === req.params.id)) { res.status(404).json({ error: 'Nicht gefunden' }); return; }
|
||||
persistedState.categories = cats.filter(c => c.id !== req.params.id);
|
||||
const fc = persistedState.fileCategories ?? {};
|
||||
for (const k of Object.keys(fc)) fc[k] = (fc[k] ?? []).filter(x => x !== req.params.id);
|
||||
writeState(); res.json({ ok: true });
|
||||
});
|
||||
app.post('/api/soundboard/categories/assign', requireAdmin, (req, res) => {
|
||||
const { files, add, remove } = req.body ?? {};
|
||||
if (!Array.isArray(files) || !files.length) { res.status(400).json({ error: 'files[] erforderlich' }); return; }
|
||||
const validCats = new Set((persistedState.categories ?? []).map(c => c.id));
|
||||
const fc = persistedState.fileCategories ?? {};
|
||||
for (const rel of files) {
|
||||
const old = new Set(fc[rel] ?? []);
|
||||
for (const a of (add ?? []).filter((id: string) => validCats.has(id))) old.add(a);
|
||||
for (const r of (remove ?? []).filter((id: string) => validCats.has(id))) old.delete(r);
|
||||
fc[rel] = Array.from(old);
|
||||
}
|
||||
persistedState.fileCategories = fc; writeState(); res.json({ ok: true, fileCategories: fc });
|
||||
});
|
||||
|
||||
// ── Badges ──
|
||||
app.post('/api/soundboard/badges/assign', requireAdmin, (req, res) => {
|
||||
const { files, add, remove } = req.body ?? {};
|
||||
if (!Array.isArray(files) || !files.length) { res.status(400).json({ error: 'files[] erforderlich' }); return; }
|
||||
const fb = persistedState.fileBadges ?? {};
|
||||
for (const rel of files) { const old = new Set(fb[rel] ?? []);
|
||||
for (const a of (add ?? [])) old.add(a); for (const r of (remove ?? [])) old.delete(r); fb[rel] = Array.from(old); }
|
||||
persistedState.fileBadges = fb; writeState(); res.json({ ok: true, fileBadges: fb });
|
||||
});
|
||||
app.post('/api/soundboard/badges/clear', requireAdmin, (req, res) => {
|
||||
const { files } = req.body ?? {};
|
||||
if (!Array.isArray(files) || !files.length) { res.status(400).json({ error: 'files[] erforderlich' }); return; }
|
||||
const fb = persistedState.fileBadges ?? {};
|
||||
for (const rel of files) delete fb[rel];
|
||||
persistedState.fileBadges = fb; writeState(); res.json({ ok: true, fileBadges: fb });
|
||||
});
|
||||
|
||||
// ── Admin: Delete & Rename ──
|
||||
app.post('/api/soundboard/admin/sounds/delete', requireAdmin, (req, res) => {
|
||||
const { paths: pathsList } = req.body ?? {};
|
||||
if (!Array.isArray(pathsList) || !pathsList.length) { res.status(400).json({ error: 'paths[] erforderlich' }); return; }
|
||||
const results: any[] = [];
|
||||
for (const rel of pathsList) {
|
||||
const full = safeSoundsPath(rel);
|
||||
if (!full) { results.push({ path: rel, ok: false, error: 'Ungültig' }); continue; }
|
||||
try {
|
||||
if (fs.existsSync(full) && fs.statSync(full).isFile()) {
|
||||
fs.unlinkSync(full);
|
||||
try { const cf = path.join(NORM_CACHE_DIR, normCacheKey(full)); if (fs.existsSync(cf)) fs.unlinkSync(cf); } catch {}
|
||||
results.push({ path: rel, ok: true });
|
||||
} else results.push({ path: rel, ok: false, error: 'nicht gefunden' });
|
||||
} catch (e: any) { results.push({ path: rel, ok: false, error: e?.message }); }
|
||||
}
|
||||
res.json({ ok: true, results });
|
||||
});
|
||||
|
||||
app.post('/api/soundboard/admin/sounds/rename', requireAdmin, (req, res) => {
|
||||
const { from, to } = req.body ?? {};
|
||||
if (!from || !to) { res.status(400).json({ error: 'from und to erforderlich' }); return; }
|
||||
const src = safeSoundsPath(from);
|
||||
if (!src) { res.status(400).json({ error: 'Ungültiger Quellpfad' }); return; }
|
||||
const parsed = path.parse(from);
|
||||
const sanitizedName = to.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
|
||||
const dstRel = path.join(parsed.dir || '', `${sanitizedName}.mp3`);
|
||||
const dst = safeSoundsPath(dstRel);
|
||||
if (!dst) { res.status(400).json({ error: 'Ungültiger Zielpfad' }); return; }
|
||||
if (!fs.existsSync(src)) { res.status(404).json({ error: 'Quelle nicht gefunden' }); return; }
|
||||
if (fs.existsSync(dst)) { res.status(409).json({ error: 'Ziel existiert bereits' }); return; }
|
||||
fs.renameSync(src, dst);
|
||||
try { const cf = path.join(NORM_CACHE_DIR, normCacheKey(src)); if (fs.existsSync(cf)) fs.unlinkSync(cf); } catch {}
|
||||
res.json({ ok: true, from, to: dstRel });
|
||||
});
|
||||
|
||||
// ── Upload ──
|
||||
const uploadStorage = multer.diskStorage({
|
||||
destination: (_req: any, _file: any, cb: (e: null, dest: string) => void) => cb(null, SOUNDS_DIR),
|
||||
filename: (_req: any, file: { originalname: string }, cb: (e: null, name: string) => void) => {
|
||||
const safe = file.originalname.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
|
||||
const { name, ext } = path.parse(safe);
|
||||
let finalName = safe; let i = 2;
|
||||
while (fs.existsSync(path.join(SOUNDS_DIR, finalName))) { finalName = `${name}-${i}${ext}`; i++; }
|
||||
cb(null, finalName);
|
||||
},
|
||||
});
|
||||
const uploadMulter = multer({ storage: uploadStorage,
|
||||
fileFilter: (_req: any, file: { originalname: string }, cb: (e: null, ok: boolean) => void) => {
|
||||
cb(null, /\.(mp3|wav)$/i.test(file.originalname));
|
||||
}, limits: { fileSize: 50 * 1024 * 1024, files: 20 } });
|
||||
|
||||
app.post('/api/soundboard/upload', requireAdmin, (req, res) => {
|
||||
uploadMulter.array('files', 20)(req, res, async (err: any) => {
|
||||
if (err) { res.status(400).json({ error: err?.message ?? 'Upload fehlgeschlagen' }); return; }
|
||||
const files = (req as any).files as any[] | undefined;
|
||||
if (!files?.length) { res.status(400).json({ error: 'Keine gültigen Dateien' }); return; }
|
||||
if (NORMALIZE_ENABLE) { for (const f of files) normalizeToCache(f.path).catch(() => {}); }
|
||||
res.json({ ok: true, files: files.map(f => ({ name: f.filename, size: f.size })) });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Health ──
|
||||
app.get('/api/soundboard/health', (_req, res) => {
|
||||
res.json({ ok: true, totalPlays: persistedState.totalPlays ?? 0, categories: (persistedState.categories ?? []).length, sounds: listAllSounds().length });
|
||||
});
|
||||
|
||||
// ── SSE Events (soundboard-specific data in main SSE stream) ──
|
||||
// The main hub SSE already exists at /api/events, snapshot data is provided via getSnapshot()
|
||||
},
|
||||
|
||||
getSnapshot() {
|
||||
const statsSnap: Record<string, any> = {};
|
||||
for (const [gId, st] of guildAudioState) {
|
||||
const status = st.connection.state?.status ?? 'unknown';
|
||||
if (status === 'ready' && !connectedSince.has(gId)) connectedSince.set(gId, new Date().toISOString());
|
||||
const ch = _pluginCtx?.client.channels.cache.get(st.channelId);
|
||||
statsSnap[gId] = { voicePing: (st.connection.ping as any)?.ws ?? null,
|
||||
gatewayPing: _pluginCtx?.client.ws.ping, status,
|
||||
channelName: ch && 'name' in ch ? (ch as any).name : null,
|
||||
connectedSince: connectedSince.get(gId) ?? null };
|
||||
}
|
||||
return {
|
||||
soundboard: {
|
||||
party: Array.from(partyActive),
|
||||
selected: persistedState?.selectedChannels ?? {},
|
||||
volumes: persistedState?.volumes ?? {},
|
||||
nowplaying: Object.fromEntries(nowPlaying),
|
||||
voicestats: statsSnap,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async destroy() {
|
||||
for (const t of partyTimers.values()) clearTimeout(t);
|
||||
partyTimers.clear(); partyActive.clear();
|
||||
for (const [gId, state] of guildAudioState) {
|
||||
try { state.player.stop(true); } catch {}
|
||||
try { state.connection.destroy(); } catch {}
|
||||
}
|
||||
guildAudioState.clear();
|
||||
if (_writeTimer) { clearTimeout(_writeTimer); writeState(); }
|
||||
console.log('[Soundboard] Destroyed');
|
||||
},
|
||||
};
|
||||
|
||||
export default soundboardPlugin;
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import RadioTab from './plugins/radio/RadioTab';
|
||||
import SoundboardTab from './plugins/soundboard/SoundboardTab';
|
||||
|
||||
interface PluginInfo {
|
||||
name: string;
|
||||
|
|
@ -10,6 +11,7 @@ interface PluginInfo {
|
|||
// Plugin tab components
|
||||
const tabComponents: Record<string, React.FC<{ data: any }>> = {
|
||||
radio: RadioTab,
|
||||
soundboard: SoundboardTab,
|
||||
};
|
||||
|
||||
export function registerTab(pluginName: string, component: React.FC<{ data: any }>) {
|
||||
|
|
|
|||
|
|
@ -100,7 +100,8 @@ export default function RadioTab({ data }: { data: any }) {
|
|||
setStationsLoading(true);
|
||||
setStations([]);
|
||||
if (globeRef.current) {
|
||||
globeRef.current.pointOfView({ lat: point.geo[0], lng: point.geo[1], altitude: 0.4 }, 800);
|
||||
// Radio Garden geo format: [lng, lat]
|
||||
globeRef.current.pointOfView({ lat: point.geo[1], lng: point.geo[0], altitude: 0.4 }, 800);
|
||||
}
|
||||
fetch(`/api/radio/place/${point.id}/channels`)
|
||||
.then(r => r.json())
|
||||
|
|
@ -123,8 +124,9 @@ export default function RadioTab({ data }: { data: any }) {
|
|||
.atmosphereColor('rgba(230, 126, 34, 0.25)')
|
||||
.atmosphereAltitude(0.12)
|
||||
.pointsData(places)
|
||||
.pointLat((d: any) => d.geo[0])
|
||||
.pointLng((d: any) => d.geo[1])
|
||||
// Radio Garden geo format: [lng, lat]
|
||||
.pointLat((d: any) => d.geo[1])
|
||||
.pointLng((d: any) => d.geo[0])
|
||||
.pointColor(() => 'rgba(230, 126, 34, 0.85)')
|
||||
.pointRadius((d: any) => Math.max(0.12, Math.min(0.45, 0.06 + (d.size ?? 1) * 0.005)))
|
||||
.pointAltitude(0.003)
|
||||
|
|
|
|||
1516
web/src/plugins/soundboard/SoundboardTab.tsx
Normal file
1516
web/src/plugins/soundboard/SoundboardTab.tsx
Normal file
File diff suppressed because it is too large
Load diff
2029
web/src/plugins/soundboard/soundboard.css
Normal file
2029
web/src/plugins/soundboard/soundboard.css
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue