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();
|
||||
|
||||
// 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 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();
|
||||
|
||||
// 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 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)
|
||||
});
|
||||
for (const [key, count] of folderCounts) {
|
||||
folders.push({ key, name: key, count });
|
||||
}
|
||||
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
|
||||
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}`);
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export default function App() {
|
|||
const [chaosMode, setChaosMode] = useState(false);
|
||||
const [partyActiveGuilds, setPartyActiveGuilds] = useState<string[]>([]);
|
||||
const chaosModeRef = useRef(false);
|
||||
const volDebounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
/* ── 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}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue