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:
parent
5a41b6a622
commit
9130a205f0
2 changed files with 57 additions and 160 deletions
|
|
@ -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 }));
|
|
||||||
|
|
||||||
const folders: Array<{ key: string; name: string; count: number }> = [];
|
// Ordner-Statistik aus allItems ableiten
|
||||||
|
const folderCounts = new Map<string, number>();
|
||||||
const subFolders = rootEntries.filter((d) => d.isDirectory());
|
for (const it of allItems) {
|
||||||
const folderItems: Array<{ fileName: string; name: string; folder: string; relativePath: string }> = [];
|
if (it.folder) folderCounts.set(it.folder, (folderCounts.get(it.folder) ?? 0) + 1);
|
||||||
for (const dirent of subFolders) {
|
}
|
||||||
const folderName = dirent.name;
|
const folders: Array<{ key: string; name: string; count: number }> = [];
|
||||||
const folderPath = path.join(SOUNDS_DIR, folderName);
|
for (const [key, count] of folderCounts) {
|
||||||
const entries = fs.readdirSync(folderPath, { withFileTypes: true });
|
folders.push({ key, name: key, count });
|
||||||
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}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue