Refactor: Backend-Optimierungen + Volume-Debounce

- /api/play delegiert an playFilePath() statt ~120 Zeilen Duplikat-Code (inkl. fehlende Loudnorm)
- safeSoundsPath() Helfer gegen Path-Traversal bei Admin delete/rename
- writePersistedStateDebounced() reduziert Disk-I/O bei Play-Countern (2s Debounce)
- /api/sounds nutzt listAllSounds() statt duplizierte Dateisystem-Scans
- /api/play-url vor catch-all Route verschoben (war unreachable in Produktion)
- Frontend Volume-Slider mit 120ms Debounce (weniger API-Calls beim Ziehen)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bot 2026-03-01 21:08:38 +01:00
parent 5a41b6a622
commit 9130a205f0
2 changed files with 57 additions and 160 deletions

View file

@ -116,16 +116,34 @@ function writePersistedState(state: PersistedState): void {
} }
const persistedState: PersistedState = readPersistedState(); const persistedState: PersistedState = readPersistedState();
// Debounced write: sammelt Schreibaufrufe und schreibt max. alle 2 Sekunden
let _writeTimer: ReturnType<typeof setTimeout> | null = null;
function writePersistedStateDebounced(): void {
if (_writeTimer) return;
_writeTimer = setTimeout(() => {
_writeTimer = null;
writePersistedState(persistedState);
}, 2000);
}
const getPersistedVolume = (guildId: string): number => { const getPersistedVolume = (guildId: string): number => {
const v = persistedState.volumes[guildId]; const v = persistedState.volumes[guildId];
return typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 1; return typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 1;
}; };
/** Sicherstellen, dass ein relativer Pfad nicht aus SOUNDS_DIR ausbricht */
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) { function incrementPlaysFor(relativePath: string) {
try { try {
const key = relativePath.replace(/\\/g, '/'); const key = relativePath.replace(/\\/g, '/');
persistedState.plays[key] = (persistedState.plays[key] ?? 0) + 1; persistedState.plays[key] = (persistedState.plays[key] ?? 0) + 1;
persistedState.totalPlays = (persistedState.totalPlays ?? 0) + 1; persistedState.totalPlays = (persistedState.totalPlays ?? 0) + 1;
writePersistedState(persistedState); writePersistedStateDebounced(); // Debounced: Play-Counter sind nicht kritisch
} catch {} } catch {}
} }
@ -699,40 +717,17 @@ app.get('/api/sounds', (req: Request, res: Response) => {
const fuzzyParam = String((req.query as any).fuzzy ?? '0'); const fuzzyParam = String((req.query as any).fuzzy ?? '0');
const useFuzzy = fuzzyParam === '1' || fuzzyParam === 'true'; const useFuzzy = fuzzyParam === '1' || fuzzyParam === 'true';
const rootEntries = fs.readdirSync(SOUNDS_DIR, { withFileTypes: true }); const allItems = listAllSounds();
const rootFiles = rootEntries
.filter((d) => {
if (!d.isFile()) return false;
const n = d.name.toLowerCase();
return n.endsWith('.mp3') || n.endsWith('.wav');
})
.map((d) => ({ fileName: d.name, name: path.parse(d.name).name, folder: '', relativePath: d.name }));
// Ordner-Statistik aus allItems ableiten
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 }> = []; const folders: Array<{ key: string; name: string; count: number }> = [];
for (const [key, count] of folderCounts) {
const subFolders = rootEntries.filter((d) => d.isDirectory()); folders.push({ key, name: key, count });
const folderItems: Array<{ fileName: string; name: string; folder: string; relativePath: string }> = [];
for (const dirent of subFolders) {
const folderName = dirent.name;
const folderPath = path.join(SOUNDS_DIR, folderName);
const entries = fs.readdirSync(folderPath, { withFileTypes: true });
const audios = entries.filter((e) => {
if (!e.isFile()) return false;
const n = e.name.toLowerCase();
return n.endsWith('.mp3') || n.endsWith('.wav');
});
for (const f of audios) {
folderItems.push({
fileName: f.name,
name: path.parse(f.name).name,
folder: folderName,
relativePath: path.join(folderName, f.name)
});
} }
folders.push({ key: folderName, name: folderName, count: audios.length });
}
const allItems = [...rootFiles, ...folderItems].sort((a, b) => a.name.localeCompare(b.name));
// Zeitstempel für Neu-Logik // Zeitstempel für Neu-Logik
type ItemWithTime = { fileName: string; name: string; folder: string; relativePath: string; mtimeMs: number }; type ItemWithTime = { fileName: string; name: string; folder: string; relativePath: string; mtimeMs: number };
@ -856,7 +851,8 @@ app.post('/api/admin/sounds/delete', requireAdmin, (req: Request, res: Response)
if (!Array.isArray(paths) || paths.length === 0) return res.status(400).json({ error: 'paths[] erforderlich' }); if (!Array.isArray(paths) || paths.length === 0) return res.status(400).json({ error: 'paths[] erforderlich' });
const results: Array<{ path: string; ok: boolean; error?: string }> = []; const results: Array<{ path: string; ok: boolean; error?: string }> = [];
for (const rel of paths) { for (const rel of paths) {
const full = path.join(SOUNDS_DIR, rel); const full = safeSoundsPath(rel);
if (!full) { results.push({ path: rel, ok: false, error: 'Ungültiger Pfad' }); continue; }
try { try {
if (fs.existsSync(full) && fs.statSync(full).isFile()) { if (fs.existsSync(full) && fs.statSync(full).isFile()) {
fs.unlinkSync(full); fs.unlinkSync(full);
@ -875,13 +871,14 @@ app.post('/api/admin/sounds/delete', requireAdmin, (req: Request, res: Response)
app.post('/api/admin/sounds/rename', requireAdmin, (req: Request, res: Response) => { app.post('/api/admin/sounds/rename', requireAdmin, (req: Request, res: Response) => {
const { from, to } = req.body as { from?: string; to?: string }; const { from, to } = req.body as { from?: string; to?: string };
if (!from || !to) return res.status(400).json({ error: 'from und to erforderlich' }); if (!from || !to) return res.status(400).json({ error: 'from und to erforderlich' });
const src = path.join(SOUNDS_DIR, from); const src = safeSoundsPath(from);
// Ziel nur Name ändern, Endung mp3 sicherstellen if (!src) return res.status(400).json({ error: 'Ungültiger Quellpfad' });
const parsed = path.parse(from); const parsed = path.parse(from);
// UTF-8 Zeichen erlauben: Leerzeichen, Umlaute, etc. - nur problematische Zeichen filtern // UTF-8 Zeichen erlauben: Leerzeichen, Umlaute, etc. - nur problematische Zeichen filtern
const sanitizedName = to.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_'); const sanitizedName = to.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
const dstRel = path.join(parsed.dir || '', `${sanitizedName}.mp3`); const dstRel = path.join(parsed.dir || '', `${sanitizedName}.mp3`);
const dst = path.join(SOUNDS_DIR, dstRel); const dst = safeSoundsPath(dstRel);
if (!dst) return res.status(400).json({ error: 'Ungültiger Zielpfad' });
try { try {
if (!fs.existsSync(src)) return res.status(404).json({ error: 'Quelle nicht gefunden' }); if (!fs.existsSync(src)) return res.status(404).json({ error: 'Quelle nicht gefunden' });
if (fs.existsSync(dst)) return res.status(409).json({ error: 'Ziel existiert bereits' }); if (fs.existsSync(dst)) return res.status(409).json({ error: 'Ziel existiert bereits' });
@ -1051,7 +1048,6 @@ app.post('/api/play', async (req: Request, res: Response) => {
let filePath: string; let filePath: string;
if (relativePath) filePath = path.join(SOUNDS_DIR, relativePath); if (relativePath) filePath = path.join(SOUNDS_DIR, relativePath);
else if (folder) { else if (folder) {
// Bevorzugt .mp3, fallback .wav
const mp3 = path.join(SOUNDS_DIR, folder, `${soundName}.mp3`); const mp3 = path.join(SOUNDS_DIR, folder, `${soundName}.mp3`);
const wav = path.join(SOUNDS_DIR, folder, `${soundName}.wav`); const wav = path.join(SOUNDS_DIR, folder, `${soundName}.wav`);
filePath = fs.existsSync(mp3) ? mp3 : wav; filePath = fs.existsSync(mp3) ? mp3 : wav;
@ -1062,112 +1058,9 @@ app.post('/api/play', async (req: Request, res: Response) => {
} }
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Sound nicht gefunden' }); if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Sound nicht gefunden' });
const guild = client.guilds.cache.get(guildId); // Delegiere an playFilePath (inkl. Loudnorm, Connection-Management, Now-Playing Broadcast)
if (!guild) return res.status(404).json({ error: 'Guild nicht gefunden' }); const relKey = relativePath || (folder ? `${folder}/${soundName}` : soundName);
const channel = guild.channels.cache.get(channelId); await playFilePath(guildId, channelId, filePath, volume, relKey!);
if (!channel || (channel.type !== ChannelType.GuildVoice && channel.type !== ChannelType.GuildStageVoice)) {
return res.status(400).json({ error: 'Ungültiger Voice-Channel' });
}
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);
// Connection State Logs
connection.on('stateChange', (oldState, newState) => {
console.log(`${new Date().toISOString()} | VoiceConnection: ${oldState.status} -> ${newState.status}`);
});
player.on('stateChange', (oldState, newState) => {
console.log(`${new Date().toISOString()} | AudioPlayer: ${oldState.status} -> ${newState.status}`);
});
player.on('error', (err) => {
console.error(`${new Date().toISOString()} | AudioPlayer error:`, err);
});
state.connection = await ensureConnectionReady(connection, channelId, guildId, guild);
attachVoiceLifecycle(state, guild);
// Stage-Channel Entstummung anfordern/setzen
try {
const me = guild.members.me;
if (me && (channel.type === ChannelType.GuildStageVoice)) {
if ((me.voice as any)?.suppress) {
await me.voice.setSuppressed(false).catch(() => me.voice.setRequestToSpeak(true));
console.log(`${new Date().toISOString()} | StageVoice: suppression versucht zu deaktivieren`);
}
}
} catch (e) {
console.warn(`${new Date().toISOString()} | StageVoice unsuppress/requestToSpeak fehlgeschlagen`, e);
}
state.player.on(AudioPlayerStatus.Idle, () => {
// optional: Verbindung bestehen lassen oder nach Timeout trennen
});
} else {
// Wechsel in anderen Channel, wenn nötig
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 = 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);
connection.on('stateChange', (o, n) => {
console.log(`${new Date().toISOString()} | VoiceConnection: ${o.status} -> ${n.status}`);
});
player.on('stateChange', (o, n) => {
console.log(`${new Date().toISOString()} | AudioPlayer: ${o.status} -> ${n.status}`);
});
player.on('error', (err) => {
console.error(`${new Date().toISOString()} | AudioPlayer error:`, err);
});
}
}
console.log(`${new Date().toISOString()} | createAudioResource: ${filePath}`);
// Volume bestimmen: bevorzugt Request-Volume, sonst bisheriger State-Wert, sonst 1
const volumeToUse = typeof volume === 'number' && Number.isFinite(volume)
? Math.max(0, Math.min(1, volume))
: (state.currentVolume ?? 1);
const resource = createAudioResource(filePath, { inlineVolume: true });
if (resource.volume) {
resource.volume.setVolume(volumeToUse);
console.log(`${new Date().toISOString()} | setVolume(${volumeToUse}) for ${soundName}`);
}
state.player.stop();
state.player.play(resource);
state.currentResource = resource;
state.currentVolume = volumeToUse;
// Persistieren
persistedState.volumes[guildId] = volumeToUse;
writePersistedState(persistedState);
console.log(`${new Date().toISOString()} | player.play() called for ${soundName}`);
// Now-Playing broadcast
nowPlaying.set(guildId!, soundName!);
sseBroadcast({ type: 'nowplaying', guildId, name: soundName });
// Plays zählen (relativer Key verfügbar?)
if (relativePath) incrementPlaysFor(relativePath);
return res.json({ ok: true }); return res.json({ ok: true });
} catch (err: any) { } catch (err: any) {
console.error('Play-Fehler:', err); console.error('Play-Fehler:', err);
@ -1343,19 +1236,6 @@ app.get('/api/events', (req: Request, res: Response) => {
}); });
}); });
// Static Frontend ausliefern (Vite build)
const webDistPath = path.resolve(__dirname, '../../web/dist');
if (fs.existsSync(webDistPath)) {
app.use(express.static(webDistPath));
app.get('*', (_req, res) => {
res.sendFile(path.join(webDistPath, 'index.html'));
});
}
app.listen(PORT, () => {
console.log(`Server läuft auf http://0.0.0.0:${PORT}`);
});
// --- Medien-URL abspielen --- // --- Medien-URL abspielen ---
// Unterstützt: direkte MP3-URL (Download und Ablage) // Unterstützt: direkte MP3-URL (Download und Ablage)
app.post('/api/play-url', async (req: Request, res: Response) => { app.post('/api/play-url', async (req: Request, res: Response) => {
@ -1391,7 +1271,18 @@ app.post('/api/play-url', async (req: Request, res: Response) => {
} }
}); });
// Upload endpoint removed (build reverted) // Static Frontend ausliefern (Vite build)
const webDistPath = path.resolve(__dirname, '../../web/dist');
if (fs.existsSync(webDistPath)) {
app.use(express.static(webDistPath));
app.get('*', (_req, res) => {
res.sendFile(path.join(webDistPath, 'index.html'));
});
}
app.listen(PORT, () => {
console.log(`Server läuft auf http://0.0.0.0:${PORT}`);
});

View file

@ -62,6 +62,7 @@ export default function App() {
const [chaosMode, setChaosMode] = useState(false); const [chaosMode, setChaosMode] = useState(false);
const [partyActiveGuilds, setPartyActiveGuilds] = useState<string[]>([]); const [partyActiveGuilds, setPartyActiveGuilds] = useState<string[]>([]);
const chaosModeRef = useRef(false); const chaosModeRef = useRef(false);
const volDebounceRef = useRef<ReturnType<typeof setTimeout>>();
/* ── Admin ── */ /* ── Admin ── */
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
@ -626,10 +627,15 @@ export default function App() {
max={1} max={1}
step={0.01} step={0.01}
value={volume} value={volume}
onChange={async e => { onChange={e => {
const v = parseFloat(e.target.value); const v = parseFloat(e.target.value);
setVolume(v); setVolume(v);
if (guildId) try { await setVolumeLive(guildId, v); } catch { } if (guildId) {
if (volDebounceRef.current) clearTimeout(volDebounceRef.current);
volDebounceRef.current = setTimeout(() => {
setVolumeLive(guildId, v).catch(() => {});
}, 120);
}
}} }}
style={{ '--vol': `${Math.round(volume * 100)}%` } as React.CSSProperties} style={{ '--vol': `${Math.round(volume * 100)}%` } as React.CSSProperties}
/> />