From 9130a205f07a3e4286a8476942e7693e855b5ecc Mon Sep 17 00:00:00 2001 From: Bot Date: Sun, 1 Mar 2026 21:08:38 +0100 Subject: [PATCH] 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 --- server/src/index.ts | 207 +++++++++++--------------------------------- web/src/App.tsx | 10 ++- 2 files changed, 57 insertions(+), 160 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index a9912f6..81f220d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -116,16 +116,34 @@ function writePersistedState(state: PersistedState): void { } const persistedState: PersistedState = readPersistedState(); + +// Debounced write: sammelt Schreibaufrufe und schreibt max. alle 2 Sekunden +let _writeTimer: ReturnType | null = null; +function writePersistedStateDebounced(): void { + if (_writeTimer) return; + _writeTimer = setTimeout(() => { + _writeTimer = null; + writePersistedState(persistedState); + }, 2000); +} + const getPersistedVolume = (guildId: string): number => { const v = persistedState.volumes[guildId]; 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) { try { const key = relativePath.replace(/\\/g, '/'); persistedState.plays[key] = (persistedState.plays[key] ?? 0) + 1; persistedState.totalPlays = (persistedState.totalPlays ?? 0) + 1; - writePersistedState(persistedState); + writePersistedStateDebounced(); // Debounced: Play-Counter sind nicht kritisch } catch {} } @@ -699,40 +717,17 @@ app.get('/api/sounds', (req: Request, res: Response) => { const fuzzyParam = String((req.query as any).fuzzy ?? '0'); const useFuzzy = fuzzyParam === '1' || fuzzyParam === 'true'; - const rootEntries = fs.readdirSync(SOUNDS_DIR, { withFileTypes: true }); - 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 })); + const allItems = listAllSounds(); - const folders: Array<{ key: string; name: string; count: number }> = []; - - const subFolders = rootEntries.filter((d) => d.isDirectory()); - 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 }); + // Ordner-Statistik aus allItems ableiten + const folderCounts = new Map(); + 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 allItems = [...rootFiles, ...folderItems].sort((a, b) => a.name.localeCompare(b.name)); // Zeitstempel für Neu-Logik 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' }); const results: Array<{ path: string; ok: boolean; error?: string }> = []; 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 { if (fs.existsSync(full) && fs.statSync(full).isFile()) { 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) => { const { from, to } = req.body as { from?: string; to?: string }; if (!from || !to) return res.status(400).json({ error: 'from und to erforderlich' }); - const src = path.join(SOUNDS_DIR, from); - // Ziel nur Name ändern, Endung mp3 sicherstellen + const src = safeSoundsPath(from); + if (!src) return res.status(400).json({ error: 'Ungültiger Quellpfad' }); const parsed = path.parse(from); // UTF-8 Zeichen erlauben: Leerzeichen, Umlaute, etc. - nur problematische Zeichen filtern const sanitizedName = to.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_'); 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 { 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' }); @@ -1051,7 +1048,6 @@ app.post('/api/play', async (req: Request, res: Response) => { let filePath: string; if (relativePath) filePath = path.join(SOUNDS_DIR, relativePath); else if (folder) { - // Bevorzugt .mp3, fallback .wav const mp3 = path.join(SOUNDS_DIR, folder, `${soundName}.mp3`); const wav = path.join(SOUNDS_DIR, folder, `${soundName}.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' }); - const guild = client.guilds.cache.get(guildId); - if (!guild) return res.status(404).json({ error: 'Guild nicht gefunden' }); - const channel = guild.channels.cache.get(channelId); - 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); + // Delegiere an playFilePath (inkl. Loudnorm, Connection-Management, Now-Playing Broadcast) + const relKey = relativePath || (folder ? `${folder}/${soundName}` : soundName); + await playFilePath(guildId, channelId, filePath, volume, relKey!); return res.json({ ok: true }); } catch (err: any) { 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 --- // Unterstützt: direkte MP3-URL (Download und Ablage) 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}`); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index d81963e..5bd90c5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -62,6 +62,7 @@ export default function App() { const [chaosMode, setChaosMode] = useState(false); const [partyActiveGuilds, setPartyActiveGuilds] = useState([]); const chaosModeRef = useRef(false); + const volDebounceRef = useRef>(); /* ── Admin ── */ const [isAdmin, setIsAdmin] = useState(false); @@ -626,10 +627,15 @@ export default function App() { max={1} step={0.01} value={volume} - onChange={async e => { + onChange={e => { const v = parseFloat(e.target.value); 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} />