2025-08-07 23:24:56 +02:00
|
|
|
|
import path from 'node:path';
|
|
|
|
|
|
import fs from 'node:fs';
|
|
|
|
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
|
|
import express, { Request, Response } from 'express';
|
2025-08-10 02:16:09 +02:00
|
|
|
|
// import multer from 'multer';
|
2025-08-07 23:24:56 +02:00
|
|
|
|
import cors from 'cors';
|
2025-08-08 14:23:18 +02:00
|
|
|
|
import crypto from 'node:crypto';
|
2025-08-10 23:10:51 +02:00
|
|
|
|
import { Client, GatewayIntentBits, Partials, ChannelType, Events, type Message, VoiceState } from 'discord.js';
|
2025-08-07 23:24:56 +02:00
|
|
|
|
import {
|
|
|
|
|
|
joinVoiceChannel,
|
|
|
|
|
|
createAudioPlayer,
|
|
|
|
|
|
createAudioResource,
|
|
|
|
|
|
AudioPlayerStatus,
|
|
|
|
|
|
NoSubscriberBehavior,
|
|
|
|
|
|
getVoiceConnection,
|
|
|
|
|
|
type VoiceConnection,
|
2025-08-08 01:40:49 +02:00
|
|
|
|
type AudioResource,
|
2025-08-08 16:42:27 +02:00
|
|
|
|
StreamType,
|
2025-08-08 00:27:12 +02:00
|
|
|
|
generateDependencyReport,
|
|
|
|
|
|
entersState,
|
|
|
|
|
|
VoiceConnectionStatus
|
2025-08-07 23:24:56 +02:00
|
|
|
|
} from '@discordjs/voice';
|
|
|
|
|
|
import sodium from 'libsodium-wrappers';
|
|
|
|
|
|
import nacl from 'tweetnacl';
|
2025-08-08 18:31:15 +02:00
|
|
|
|
// Streaming externer Plattformen entfernt – nur MP3-URLs werden noch unterstützt
|
2025-08-08 20:05:03 +02:00
|
|
|
|
import child_process from 'node:child_process';
|
2026-03-01 21:24:47 +01:00
|
|
|
|
import { PassThrough } from 'node:stream';
|
2025-08-07 23:24:56 +02:00
|
|
|
|
|
|
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
|
|
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
|
|
|
|
|
|
|
|
// --- Config ---
|
|
|
|
|
|
const PORT = Number(process.env.PORT ?? 8080);
|
|
|
|
|
|
const SOUNDS_DIR = process.env.SOUNDS_DIR ?? '/data/sounds';
|
|
|
|
|
|
const DISCORD_TOKEN = process.env.DISCORD_TOKEN ?? '';
|
2025-08-08 14:23:18 +02:00
|
|
|
|
const ADMIN_PWD = process.env.ADMIN_PWD ?? '';
|
2025-08-07 23:24:56 +02:00
|
|
|
|
const ALLOWED_GUILD_IDS = (process.env.ALLOWED_GUILD_IDS ?? '')
|
|
|
|
|
|
.split(',')
|
|
|
|
|
|
.map((s) => s.trim())
|
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
|
|
|
|
|
|
if (!DISCORD_TOKEN) {
|
|
|
|
|
|
console.error('Fehlende Umgebungsvariable DISCORD_TOKEN');
|
|
|
|
|
|
process.exit(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fs.mkdirSync(SOUNDS_DIR, { recursive: true });
|
|
|
|
|
|
|
2025-08-09 17:16:37 +02:00
|
|
|
|
// Persistenter Zustand: Lautstärke/Plays + Kategorien
|
|
|
|
|
|
type Category = { id: string; name: string; color?: string; sort?: number };
|
2025-08-10 18:47:33 +02:00
|
|
|
|
type PersistedState = {
|
2025-08-09 17:16:37 +02:00
|
|
|
|
volumes: Record<string, number>;
|
|
|
|
|
|
plays: Record<string, number>;
|
|
|
|
|
|
totalPlays: number;
|
|
|
|
|
|
categories?: Category[];
|
|
|
|
|
|
fileCategories?: Record<string, string[]>; // relPath or fileName -> categoryIds[]
|
2025-08-09 17:27:17 +02:00
|
|
|
|
fileBadges?: Record<string, string[]>; // relPath or fileName -> custom badges (emoji/text)
|
2025-08-10 18:47:33 +02:00
|
|
|
|
selectedChannels?: Record<string, string>; // guildId -> channelId (serverweite Auswahl)
|
2025-08-10 23:10:51 +02:00
|
|
|
|
entranceSounds?: Record<string, string>; // userId -> relativePath or fileName
|
|
|
|
|
|
exitSounds?: Record<string, string>; // userId -> relativePath or fileName
|
2025-08-09 17:16:37 +02:00
|
|
|
|
};
|
2025-08-09 00:31:46 +02:00
|
|
|
|
// Neuer, persistenter Speicherort direkt im Sounds-Volume
|
|
|
|
|
|
const STATE_FILE_NEW = path.join(SOUNDS_DIR, 'state.json');
|
|
|
|
|
|
// Alter Speicherort (eine Ebene über SOUNDS_DIR). Wird für Migration gelesen, falls vorhanden.
|
|
|
|
|
|
const STATE_FILE_OLD = path.join(path.resolve(SOUNDS_DIR, '..'), 'state.json');
|
2025-08-08 13:46:27 +02:00
|
|
|
|
|
|
|
|
|
|
function readPersistedState(): PersistedState {
|
|
|
|
|
|
try {
|
2025-08-09 00:31:46 +02:00
|
|
|
|
// 1) Bevorzugt neuen Speicherort lesen
|
|
|
|
|
|
if (fs.existsSync(STATE_FILE_NEW)) {
|
|
|
|
|
|
const raw = fs.readFileSync(STATE_FILE_NEW, 'utf8');
|
2025-08-08 13:46:27 +02:00
|
|
|
|
const parsed = JSON.parse(raw);
|
2025-08-09 17:16:37 +02:00
|
|
|
|
return {
|
|
|
|
|
|
volumes: parsed.volumes ?? {},
|
|
|
|
|
|
plays: parsed.plays ?? {},
|
|
|
|
|
|
totalPlays: parsed.totalPlays ?? 0,
|
|
|
|
|
|
categories: Array.isArray(parsed.categories) ? parsed.categories : [],
|
2025-08-09 17:27:17 +02:00
|
|
|
|
fileCategories: parsed.fileCategories ?? {},
|
2025-08-10 18:47:33 +02:00
|
|
|
|
fileBadges: parsed.fileBadges ?? {},
|
2025-08-10 23:10:51 +02:00
|
|
|
|
selectedChannels: parsed.selectedChannels ?? {},
|
|
|
|
|
|
entranceSounds: parsed.entranceSounds ?? {},
|
|
|
|
|
|
exitSounds: parsed.exitSounds ?? {}
|
2025-08-09 17:16:37 +02:00
|
|
|
|
} as PersistedState;
|
2025-08-08 13:46:27 +02:00
|
|
|
|
}
|
2025-08-09 00:31:46 +02:00
|
|
|
|
// 2) Fallback: alten Speicherort lesen und sofort nach NEW migrieren
|
|
|
|
|
|
if (fs.existsSync(STATE_FILE_OLD)) {
|
|
|
|
|
|
const raw = fs.readFileSync(STATE_FILE_OLD, 'utf8');
|
|
|
|
|
|
const parsed = JSON.parse(raw);
|
2025-08-09 17:16:37 +02:00
|
|
|
|
const migrated: PersistedState = {
|
|
|
|
|
|
volumes: parsed.volumes ?? {},
|
|
|
|
|
|
plays: parsed.plays ?? {},
|
|
|
|
|
|
totalPlays: parsed.totalPlays ?? 0,
|
|
|
|
|
|
categories: Array.isArray(parsed.categories) ? parsed.categories : [],
|
2025-08-09 17:27:17 +02:00
|
|
|
|
fileCategories: parsed.fileCategories ?? {},
|
2025-08-10 18:47:33 +02:00
|
|
|
|
fileBadges: parsed.fileBadges ?? {},
|
2025-08-10 23:10:51 +02:00
|
|
|
|
selectedChannels: parsed.selectedChannels ?? {},
|
|
|
|
|
|
entranceSounds: parsed.entranceSounds ?? {},
|
|
|
|
|
|
exitSounds: parsed.exitSounds ?? {}
|
2025-08-09 17:16:37 +02:00
|
|
|
|
};
|
2025-08-09 00:31:46 +02:00
|
|
|
|
try {
|
|
|
|
|
|
fs.mkdirSync(path.dirname(STATE_FILE_NEW), { recursive: true });
|
|
|
|
|
|
fs.writeFileSync(STATE_FILE_NEW, JSON.stringify(migrated, null, 2), 'utf8');
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
return migrated;
|
|
|
|
|
|
}
|
2025-08-08 13:46:27 +02:00
|
|
|
|
} catch {}
|
2025-08-08 20:50:11 +02:00
|
|
|
|
return { volumes: {}, plays: {}, totalPlays: 0 };
|
2025-08-08 13:46:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function writePersistedState(state: PersistedState): void {
|
|
|
|
|
|
try {
|
2025-08-09 00:31:46 +02:00
|
|
|
|
fs.mkdirSync(path.dirname(STATE_FILE_NEW), { recursive: true });
|
|
|
|
|
|
fs.writeFileSync(STATE_FILE_NEW, JSON.stringify(state, null, 2), 'utf8');
|
2025-08-08 13:46:27 +02:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn('Persisted state konnte nicht geschrieben werden:', e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const persistedState: PersistedState = readPersistedState();
|
2026-03-01 21:08:38 +01:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-08 13:46:27 +02:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
2026-03-01 21:08:38 +01:00
|
|
|
|
/** 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-08 20:05:03 +02:00
|
|
|
|
function incrementPlaysFor(relativePath: string) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const key = relativePath.replace(/\\/g, '/');
|
|
|
|
|
|
persistedState.plays[key] = (persistedState.plays[key] ?? 0) + 1;
|
2025-08-08 20:50:11 +02:00
|
|
|
|
persistedState.totalPlays = (persistedState.totalPlays ?? 0) + 1;
|
2026-03-01 21:08:38 +01:00
|
|
|
|
writePersistedStateDebounced(); // Debounced: Play-Counter sind nicht kritisch
|
2025-08-08 20:05:03 +02:00
|
|
|
|
} catch {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Normalisierung (ffmpeg loudnorm) Konfiguration
|
|
|
|
|
|
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');
|
2025-08-08 13:46:27 +02:00
|
|
|
|
|
2026-03-01 21:16:56 +01:00
|
|
|
|
// Loudnorm-Cache: normalisierte PCM-Dateien werden gecacht, damit ffmpeg nur 1× pro Sound läuft
|
|
|
|
|
|
const NORM_CACHE_DIR = path.join(SOUNDS_DIR, '.norm-cache');
|
|
|
|
|
|
fs.mkdirSync(NORM_CACHE_DIR, { recursive: true });
|
|
|
|
|
|
|
|
|
|
|
|
/** Erzeugt einen Cache-Key aus dem relativen Pfad (Slash-normalisiert, Sonderzeichen escaped) */
|
|
|
|
|
|
function normCacheKey(filePath: string): string {
|
|
|
|
|
|
const rel = path.relative(SOUNDS_DIR, filePath).replace(/\\/g, '/');
|
|
|
|
|
|
return rel.replace(/[/\\:*?"<>|]/g, '_') + '.pcm';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Liefert den Pfad zur gecachten normalisierten PCM-Datei, oder null wenn kein gültiger Cache existiert */
|
|
|
|
|
|
function getNormCachePath(filePath: string): string | null {
|
|
|
|
|
|
const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath));
|
|
|
|
|
|
if (!fs.existsSync(cacheFile)) return null;
|
|
|
|
|
|
// Invalidieren wenn Quelldatei neuer als Cache
|
|
|
|
|
|
try {
|
|
|
|
|
|
const srcMtime = fs.statSync(filePath).mtimeMs;
|
|
|
|
|
|
const cacheMtime = fs.statSync(cacheFile).mtimeMs;
|
|
|
|
|
|
if (srcMtime > cacheMtime) { try { fs.unlinkSync(cacheFile); } catch {} return null; }
|
|
|
|
|
|
} catch { return null; }
|
|
|
|
|
|
return cacheFile;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Normalisiert eine Audiodatei via ffmpeg loudnorm und speichert das Ergebnis im Cache */
|
|
|
|
|
|
function normalizeToCache(filePath: string): Promise<string> {
|
|
|
|
|
|
const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath));
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
2026-03-01 21:27:33 +01:00
|
|
|
|
const ffArgs = ['-hide_banner', '-loglevel', 'error', '-y', '-i', filePath,
|
2026-03-01 21:16:56 +01:00
|
|
|
|
'-af', `loudnorm=I=${NORMALIZE_I}:LRA=${NORMALIZE_LRA}:TP=${NORMALIZE_TP}`,
|
|
|
|
|
|
'-f', 's16le', '-ar', '48000', '-ac', '2', cacheFile];
|
|
|
|
|
|
const ff = child_process.spawn('ffmpeg', ffArgs);
|
|
|
|
|
|
ff.on('error', reject);
|
|
|
|
|
|
ff.on('close', (code) => {
|
|
|
|
|
|
if (code === 0) resolve(cacheFile);
|
|
|
|
|
|
else reject(new Error(`ffmpeg exited with code ${code}`));
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 21:29:53 +01:00
|
|
|
|
// Wie viele ffmpeg-Prozesse parallel beim Cache-Sync laufen dürfen.
|
2026-03-01 21:30:37 +01:00
|
|
|
|
// Standard: 2 (konservativ – lässt genug Headroom für Discord-Wiedergabe und Node.js).
|
|
|
|
|
|
// Empfehlung: i5/i7 (8-16 Kerne) → 2-3, Ryzen 9 / Xeon → 4-8
|
|
|
|
|
|
// Über NORM_CONCURRENCY=4 env var erhöhbar.
|
|
|
|
|
|
const NORM_CONCURRENCY = Math.max(1, Number(process.env.NORM_CONCURRENCY ?? 2));
|
2026-03-01 21:29:53 +01:00
|
|
|
|
|
2026-03-01 21:27:33 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Vollständige Cache-Synchronisation:
|
2026-03-01 21:29:53 +01:00
|
|
|
|
* 1. Alle Sound-Dateien normalisieren, die noch nicht im Cache sind (parallel)
|
2026-03-01 21:27:33 +01:00
|
|
|
|
* 2. Verwaiste Cache-Dateien löschen (Sound wurde gelöscht/umbenannt)
|
|
|
|
|
|
* Läuft im Hintergrund, blockiert nicht den Server.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function syncNormCache(): Promise<void> {
|
|
|
|
|
|
if (!NORMALIZE_ENABLE) return;
|
|
|
|
|
|
const t0 = Date.now();
|
|
|
|
|
|
const allSounds = listAllSounds();
|
|
|
|
|
|
|
|
|
|
|
|
// Set aller erwarteten Cache-Keys
|
|
|
|
|
|
const expectedKeys = new Set<string>();
|
2026-03-01 21:29:53 +01:00
|
|
|
|
const toProcess: string[] = [];
|
2026-03-01 21:27:33 +01:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-03-01 21:29:53 +01:00
|
|
|
|
if (getNormCachePath(fp)) continue; // bereits gecacht & gültig
|
|
|
|
|
|
toProcess.push(fp);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let created = 0;
|
|
|
|
|
|
let failed = 0;
|
|
|
|
|
|
const skipped = allSounds.length - toProcess.length;
|
|
|
|
|
|
|
|
|
|
|
|
// Parallele Worker-Pool: NORM_CONCURRENCY ffmpeg-Prozesse gleichzeitig
|
|
|
|
|
|
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(`Norm-cache failed: ${path.relative(SOUNDS_DIR, fp)}`, e);
|
|
|
|
|
|
}
|
2026-03-01 21:27:33 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-01 21:29:53 +01:00
|
|
|
|
const workers = Array.from({ length: Math.min(NORM_CONCURRENCY, toProcess.length || 1) }, worker);
|
|
|
|
|
|
await Promise.all(workers);
|
2026-03-01 21:27:33 +01:00
|
|
|
|
|
|
|
|
|
|
// Verwaiste Cache-Dateien aufräumen
|
|
|
|
|
|
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 {}
|
|
|
|
|
|
|
|
|
|
|
|
const dt = ((Date.now() - t0) / 1000).toFixed(1);
|
|
|
|
|
|
console.log(
|
2026-03-01 21:29:53 +01:00
|
|
|
|
`Norm-Cache sync (${dt}s, ${NORM_CONCURRENCY} parallel): ${created} neu, ${skipped} vorhanden, ${failed} fehlgeschlagen, ${cleaned} verwaist entfernt (${allSounds.length} Sounds gesamt)`
|
2026-03-01 21:27:33 +01:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-07 23:24:56 +02:00
|
|
|
|
// --- Voice Abhängigkeiten prüfen ---
|
|
|
|
|
|
await sodium.ready;
|
|
|
|
|
|
// init nacl to ensure it loads
|
|
|
|
|
|
void nacl.randomBytes(1);
|
|
|
|
|
|
console.log(generateDependencyReport());
|
|
|
|
|
|
|
|
|
|
|
|
// --- Discord Client ---
|
|
|
|
|
|
const client = new Client({
|
2025-08-10 23:26:00 +02:00
|
|
|
|
intents: [
|
|
|
|
|
|
GatewayIntentBits.Guilds,
|
|
|
|
|
|
GatewayIntentBits.GuildVoiceStates,
|
|
|
|
|
|
GatewayIntentBits.DirectMessages,
|
|
|
|
|
|
GatewayIntentBits.MessageContent,
|
|
|
|
|
|
],
|
2025-08-07 23:24:56 +02:00
|
|
|
|
partials: [Partials.Channel]
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-08-08 01:26:45 +02:00
|
|
|
|
type GuildAudioState = {
|
|
|
|
|
|
connection: VoiceConnection;
|
|
|
|
|
|
player: ReturnType<typeof createAudioPlayer>;
|
|
|
|
|
|
guildId: string;
|
|
|
|
|
|
channelId: string;
|
2025-08-08 01:40:49 +02:00
|
|
|
|
currentResource?: AudioResource;
|
|
|
|
|
|
currentVolume: number; // 0..1
|
2025-08-08 01:26:45 +02:00
|
|
|
|
};
|
2025-08-07 23:24:56 +02:00
|
|
|
|
const guildAudioState = new Map<string, GuildAudioState>();
|
2025-08-09 22:43:13 +02:00
|
|
|
|
// Partymode: serverseitige Steuerung (global pro Guild)
|
|
|
|
|
|
const partyTimers = new Map<string, NodeJS.Timeout>();
|
|
|
|
|
|
const partyActive = new Set<string>();
|
2026-03-01 16:00:22 +01:00
|
|
|
|
// Now-Playing: aktuell gespielter Sound pro Guild
|
|
|
|
|
|
const nowPlaying = new Map<string, string>();
|
2025-08-09 23:20:13 +02:00
|
|
|
|
// SSE-Klienten für Broadcasts (z.B. Partymode Status)
|
|
|
|
|
|
const sseClients = new Set<Response>();
|
|
|
|
|
|
function sseBroadcast(payload: any) {
|
|
|
|
|
|
const data = `data: ${JSON.stringify(payload)}\n\n`;
|
|
|
|
|
|
for (const res of sseClients) {
|
|
|
|
|
|
try { res.write(data); } catch {}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-08-07 23:24:56 +02:00
|
|
|
|
|
2025-08-10 18:47:33 +02:00
|
|
|
|
// Hilfsfunktionen für serverweit ausgewählten Channel pro Guild
|
|
|
|
|
|
function getSelectedChannelForGuild(guildId: string): string | undefined {
|
|
|
|
|
|
const id = String(guildId || '');
|
|
|
|
|
|
if (!id) return undefined;
|
|
|
|
|
|
const sc = persistedState.selectedChannels ?? {};
|
|
|
|
|
|
return sc[id];
|
|
|
|
|
|
}
|
|
|
|
|
|
function setSelectedChannelForGuild(guildId: string, channelId: string): void {
|
|
|
|
|
|
const g = String(guildId || '');
|
|
|
|
|
|
const c = String(channelId || '');
|
|
|
|
|
|
if (!g || !c) return;
|
|
|
|
|
|
if (!persistedState.selectedChannels) persistedState.selectedChannels = {};
|
|
|
|
|
|
persistedState.selectedChannels[g] = c;
|
|
|
|
|
|
writePersistedState(persistedState);
|
|
|
|
|
|
sseBroadcast({ type: 'channel', guildId: g, channelId: c });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-08 20:05:03 +02:00
|
|
|
|
async function playFilePath(guildId: string, channelId: string, filePath: string, volume?: number, relativeKey?: string): Promise<void> {
|
2025-08-08 18:40:40 +02:00
|
|
|
|
const guild = 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);
|
|
|
|
|
|
}
|
2025-08-10 23:35:58 +02:00
|
|
|
|
// Wenn der Bot in einer anderen ChannelId ist, sauber rüberwechseln
|
|
|
|
|
|
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
|
|
|
|
|
|
});
|
2025-08-10 23:50:51 +02:00
|
|
|
|
// Reuse bestehenden Player falls vorhanden
|
2025-08-10 23:35:58 +02:00
|
|
|
|
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 {}
|
2025-08-10 23:50:51 +02:00
|
|
|
|
|
|
|
|
|
|
// Falls keine aktive Verbindung existiert (oder nach destroy), sicherstellen
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
2025-08-08 18:40:40 +02:00
|
|
|
|
const useVolume = typeof volume === 'number' && Number.isFinite(volume)
|
|
|
|
|
|
? Math.max(0, Math.min(1, volume))
|
|
|
|
|
|
: (state.currentVolume ?? 1);
|
2025-08-08 20:05:03 +02:00
|
|
|
|
let resource: AudioResource;
|
|
|
|
|
|
if (NORMALIZE_ENABLE) {
|
2026-03-01 21:24:47 +01:00
|
|
|
|
const cachedPath = getNormCachePath(filePath);
|
|
|
|
|
|
if (cachedPath) {
|
|
|
|
|
|
// Cache-Hit: gecachte PCM-Datei als Stream lesen (kein ffmpeg, instant)
|
|
|
|
|
|
const pcmStream = fs.createReadStream(cachedPath);
|
|
|
|
|
|
resource = createAudioResource(pcmStream, { inlineVolume: true, inputType: StreamType.Raw });
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Cache-Miss: ffmpeg streamen (sofortige Wiedergabe) UND gleichzeitig in Cache schreiben
|
|
|
|
|
|
const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath));
|
|
|
|
|
|
const ffArgs = ['-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 ff = child_process.spawn('ffmpeg', ffArgs);
|
|
|
|
|
|
// Tee: Daten gleichzeitig an Player und Cache-Datei
|
|
|
|
|
|
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();
|
2026-03-01 21:16:56 +01:00
|
|
|
|
console.log(`${new Date().toISOString()} | Loudnorm cached: ${path.basename(filePath)}`);
|
2026-03-01 21:24:47 +01:00
|
|
|
|
});
|
|
|
|
|
|
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 });
|
2026-03-01 21:16:56 +01:00
|
|
|
|
}
|
2025-08-08 20:05:03 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
resource = createAudioResource(filePath, { inlineVolume: true });
|
|
|
|
|
|
}
|
2025-08-08 18:40:40 +02:00
|
|
|
|
if (resource.volume) resource.volume.setVolume(useVolume);
|
|
|
|
|
|
state.player.stop();
|
|
|
|
|
|
state.player.play(resource);
|
|
|
|
|
|
state.currentResource = resource;
|
|
|
|
|
|
state.currentVolume = useVolume;
|
2026-03-01 16:00:22 +01:00
|
|
|
|
// Now-Playing broadcast
|
|
|
|
|
|
const soundLabel = relativeKey ? path.parse(relativeKey).name : path.parse(filePath).name;
|
|
|
|
|
|
nowPlaying.set(guildId, soundLabel);
|
|
|
|
|
|
sseBroadcast({ type: 'nowplaying', guildId, name: soundLabel });
|
2025-08-08 20:05:03 +02:00
|
|
|
|
if (relativeKey) incrementPlaysFor(relativeKey);
|
2025-08-08 18:40:40 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-08 19:31:02 +02:00
|
|
|
|
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(
|
|
|
|
|
|
'Available commands\n' +
|
|
|
|
|
|
'?help - zeigt diese Hilfe\n' +
|
2025-08-09 00:00:55 +02:00
|
|
|
|
'?list - listet alle Audio-Dateien (mp3/wav)\n' +
|
2025-08-11 00:00:08 +02:00
|
|
|
|
'?entrance <datei.mp3|datei.wav> | remove - setze oder entferne deinen Entrance-Sound\n' +
|
|
|
|
|
|
'?exit <datei.mp3|datei.wav> | remove - setze oder entferne deinen Exit-Sound\n'
|
2025-08-08 19:31:02 +02:00
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (cmd === '?list') {
|
2025-08-09 00:00:55 +02:00
|
|
|
|
const files = fs
|
|
|
|
|
|
.readdirSync(SOUNDS_DIR)
|
|
|
|
|
|
.filter(f => { const l = f.toLowerCase(); return l.endsWith('.mp3') || l.endsWith('.wav'); });
|
2025-08-08 19:31:02 +02:00
|
|
|
|
await reply(files.length ? files.join('\n') : 'Keine Dateien gefunden.');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-08-10 23:10:51 +02:00
|
|
|
|
if (cmd === '?entrance') {
|
2025-08-11 00:00:08 +02:00
|
|
|
|
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: ?entrance <datei.mp3|datei.wav> | remove'); return; }
|
|
|
|
|
|
if (/^(remove|clear|delete)$/i.test(fileName)) {
|
|
|
|
|
|
persistedState.entranceSounds = persistedState.entranceSounds ?? {};
|
|
|
|
|
|
delete persistedState.entranceSounds[userId];
|
|
|
|
|
|
writePersistedState(persistedState);
|
|
|
|
|
|
try { console.log(`${new Date().toISOString()} | Entrance removed: user=${userId} (${message.author?.tag || 'unknown'})`); } catch {}
|
|
|
|
|
|
await reply('Entrance-Sound entfernt.');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-08-10 23:10:51 +02:00
|
|
|
|
const lower = fileName.toLowerCase();
|
|
|
|
|
|
if (!(lower.endsWith('.mp3') || lower.endsWith('.wav'))) { await reply('Nur .mp3 oder .wav Dateien sind erlaubt'); return; }
|
|
|
|
|
|
const resolve = (() => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const direct = path.join(SOUNDS_DIR, fileName); if (fs.existsSync(direct)) return fileName;
|
|
|
|
|
|
const dirs = fs.readdirSync(SOUNDS_DIR, { withFileTypes: true });
|
|
|
|
|
|
for (const d of dirs) { if (!d.isDirectory()) continue; const cand = path.join(SOUNDS_DIR, d.name, fileName); if (fs.existsSync(cand)) return path.join(d.name, fileName).replace(/\\/g, '/'); }
|
|
|
|
|
|
return '';
|
|
|
|
|
|
} catch { return ''; }
|
|
|
|
|
|
})();
|
|
|
|
|
|
if (!resolve) { await reply('Datei nicht gefunden. Nutze ?list.'); return; }
|
2025-08-10 23:43:40 +02:00
|
|
|
|
persistedState.entranceSounds = persistedState.entranceSounds ?? {};
|
|
|
|
|
|
persistedState.entranceSounds[userId] = resolve;
|
|
|
|
|
|
writePersistedState(persistedState);
|
|
|
|
|
|
try {
|
|
|
|
|
|
console.log(`${new Date().toISOString()} | Entrance set: user=${userId} (${message.author?.tag || 'unknown'}) file=${resolve}`);
|
|
|
|
|
|
} catch {}
|
2025-08-10 23:10:51 +02:00
|
|
|
|
await reply(`Entrance-Sound gesetzt: ${resolve}`); return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (cmd === '?exit') {
|
2025-08-11 00:00:08 +02:00
|
|
|
|
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: ?exit <datei.mp3|datei.wav> | remove'); return; }
|
|
|
|
|
|
if (/^(remove|clear|delete)$/i.test(fileName)) {
|
|
|
|
|
|
persistedState.exitSounds = persistedState.exitSounds ?? {};
|
|
|
|
|
|
delete persistedState.exitSounds[userId];
|
|
|
|
|
|
writePersistedState(persistedState);
|
|
|
|
|
|
try { console.log(`${new Date().toISOString()} | Exit removed: user=${userId} (${message.author?.tag || 'unknown'})`); } catch {}
|
|
|
|
|
|
await reply('Exit-Sound entfernt.');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-08-10 23:10:51 +02:00
|
|
|
|
const lower = fileName.toLowerCase();
|
|
|
|
|
|
if (!(lower.endsWith('.mp3') || lower.endsWith('.wav'))) { await reply('Nur .mp3 oder .wav Dateien sind erlaubt'); return; }
|
|
|
|
|
|
const resolve = (() => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const direct = path.join(SOUNDS_DIR, fileName); if (fs.existsSync(direct)) return fileName;
|
|
|
|
|
|
const dirs = fs.readdirSync(SOUNDS_DIR, { withFileTypes: true });
|
|
|
|
|
|
for (const d of dirs) { if (!d.isDirectory()) continue; const cand = path.join(SOUNDS_DIR, d.name, fileName); if (fs.existsSync(cand)) return path.join(d.name, fileName).replace(/\\/g, '/'); }
|
|
|
|
|
|
return '';
|
|
|
|
|
|
} catch { return ''; }
|
|
|
|
|
|
})();
|
|
|
|
|
|
if (!resolve) { await reply('Datei nicht gefunden. Nutze ?list.'); return; }
|
2025-08-10 23:43:40 +02:00
|
|
|
|
persistedState.exitSounds = persistedState.exitSounds ?? {};
|
|
|
|
|
|
persistedState.exitSounds[userId] = resolve;
|
|
|
|
|
|
writePersistedState(persistedState);
|
|
|
|
|
|
try {
|
|
|
|
|
|
console.log(`${new Date().toISOString()} | Exit set: user=${userId} (${message.author?.tag || 'unknown'}) file=${resolve}`);
|
|
|
|
|
|
} catch {}
|
2025-08-10 23:10:51 +02:00
|
|
|
|
await reply(`Exit-Sound gesetzt: ${resolve}`); return;
|
2025-08-08 19:31:02 +02:00
|
|
|
|
}
|
|
|
|
|
|
await reply('Unbekannter Command. Nutze ?help.');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-08 00:31:23 +02:00
|
|
|
|
async function ensureConnectionReady(connection: VoiceConnection, channelId: string, guildId: string, guild: any): Promise<VoiceConnection> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await entersState(connection, VoiceConnectionStatus.Ready, 15_000);
|
|
|
|
|
|
console.log(`${new Date().toISOString()} | VoiceConnection ready`);
|
|
|
|
|
|
return connection;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn(`${new Date().toISOString()} | VoiceConnection not ready, trying rejoin...`, e);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
connection.rejoin({ channelId, selfDeaf: false, selfMute: false });
|
|
|
|
|
|
await entersState(connection, VoiceConnectionStatus.Ready, 15_000);
|
|
|
|
|
|
console.log(`${new Date().toISOString()} | VoiceConnection ready after rejoin`);
|
|
|
|
|
|
return connection;
|
|
|
|
|
|
} catch (e2) {
|
|
|
|
|
|
console.error(`${new Date().toISOString()} | VoiceConnection still not ready after rejoin`, e2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
connection.destroy();
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
const newConn = joinVoiceChannel({
|
|
|
|
|
|
channelId,
|
|
|
|
|
|
guildId,
|
|
|
|
|
|
adapterCreator: guild.voiceAdapterCreator as any,
|
|
|
|
|
|
selfMute: false,
|
|
|
|
|
|
selfDeaf: false
|
|
|
|
|
|
});
|
|
|
|
|
|
await entersState(newConn, VoiceConnectionStatus.Ready, 15_000).catch((e3) => {
|
|
|
|
|
|
console.error(`${new Date().toISOString()} | VoiceConnection not ready after fresh join`, e3);
|
|
|
|
|
|
});
|
|
|
|
|
|
return newConn;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-08 01:26:45 +02:00
|
|
|
|
function attachVoiceLifecycle(state: GuildAudioState, guild: any) {
|
|
|
|
|
|
const { connection } = state;
|
2025-08-11 00:14:07 +02:00
|
|
|
|
// Mehrfach-Registrierung verhindern
|
|
|
|
|
|
if ((connection as any).__lifecycleAttached) return;
|
|
|
|
|
|
try { (connection as any).setMaxListeners?.(0); } catch {}
|
2025-08-08 01:26:45 +02:00
|
|
|
|
connection.on('stateChange', async (oldS: any, newS: any) => {
|
|
|
|
|
|
console.log(`${new Date().toISOString()} | VoiceConnection: ${oldS.status} -> ${newS.status}`);
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (newS.status === VoiceConnectionStatus.Disconnected) {
|
|
|
|
|
|
// Versuche, die Verbindung kurzfristig neu auszuhandeln, sonst rejoin
|
|
|
|
|
|
try {
|
|
|
|
|
|
await Promise.race([
|
|
|
|
|
|
entersState(connection, VoiceConnectionStatus.Signalling, 5_000),
|
|
|
|
|
|
entersState(connection, VoiceConnectionStatus.Connecting, 5_000)
|
|
|
|
|
|
]);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false });
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (newS.status === VoiceConnectionStatus.Destroyed) {
|
|
|
|
|
|
// Komplett neu beitreten
|
|
|
|
|
|
const newConn = joinVoiceChannel({
|
|
|
|
|
|
channelId: state.channelId,
|
|
|
|
|
|
guildId: state.guildId,
|
|
|
|
|
|
adapterCreator: guild.voiceAdapterCreator as any,
|
|
|
|
|
|
selfMute: false,
|
|
|
|
|
|
selfDeaf: false
|
|
|
|
|
|
});
|
|
|
|
|
|
state.connection = newConn;
|
|
|
|
|
|
newConn.subscribe(state.player);
|
|
|
|
|
|
attachVoiceLifecycle(state, guild);
|
|
|
|
|
|
} else if (newS.status === VoiceConnectionStatus.Connecting || newS.status === VoiceConnectionStatus.Signalling) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await entersState(connection, VoiceConnectionStatus.Ready, 15_000);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn(`${new Date().toISOString()} | Voice not ready from ${newS.status}, rejoin`, e);
|
|
|
|
|
|
connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error(`${new Date().toISOString()} | Voice lifecycle handler error`, e);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2025-08-11 00:14:07 +02:00
|
|
|
|
(connection as any).__lifecycleAttached = true;
|
2025-08-08 01:26:45 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-07 23:24:56 +02:00
|
|
|
|
client.once(Events.ClientReady, () => {
|
|
|
|
|
|
console.log(`Bot eingeloggt als ${client.user?.tag}`);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-08-10 23:10:51 +02:00
|
|
|
|
// Voice State Updates: Entrance/Exit
|
|
|
|
|
|
client.on(Events.VoiceStateUpdate, async (oldState: VoiceState, newState: VoiceState) => {
|
|
|
|
|
|
try {
|
2025-08-10 23:35:58 +02:00
|
|
|
|
const userId = (newState.id || oldState.id) as string;
|
2025-08-10 23:10:51 +02:00
|
|
|
|
if (!userId) return;
|
2025-08-10 23:35:58 +02:00
|
|
|
|
// Eigene Events ignorieren
|
|
|
|
|
|
if (userId === client.user?.id) return;
|
2025-08-10 23:10:51 +02:00
|
|
|
|
const guildId = (newState.guild?.id || oldState.guild?.id) as string;
|
|
|
|
|
|
if (!guildId) return;
|
|
|
|
|
|
|
|
|
|
|
|
const before = oldState.channelId;
|
|
|
|
|
|
const after = newState.channelId;
|
2025-08-10 23:35:58 +02:00
|
|
|
|
console.log(`${new Date().toISOString()} | VoiceStateUpdate user=${userId} before=${before ?? '-'} after=${after ?? '-'}`);
|
2025-08-10 23:10:51 +02:00
|
|
|
|
|
2025-08-10 23:50:51 +02:00
|
|
|
|
// Entrance: Nutzer betritt einen Channel (erstmaliger Join oder Wechsel)
|
|
|
|
|
|
if (after && before !== after) {
|
|
|
|
|
|
console.log(`${new Date().toISOString()} | Entrance condition met for user=${userId} before=${before ?? '-'} after=${after}`);
|
2025-08-10 23:10:51 +02:00
|
|
|
|
const mapping = persistedState.entranceSounds ?? {};
|
|
|
|
|
|
const file = mapping[userId];
|
|
|
|
|
|
if (file) {
|
|
|
|
|
|
const rel = file.replace(/\\/g, '/');
|
|
|
|
|
|
const abs = path.join(SOUNDS_DIR, rel);
|
|
|
|
|
|
if (fs.existsSync(abs)) {
|
2025-08-10 23:18:43 +02:00
|
|
|
|
try {
|
|
|
|
|
|
// Dem Channel beitreten und Sound spielen
|
|
|
|
|
|
await playFilePath(guildId, after, abs, undefined, rel);
|
2025-08-10 23:35:58 +02:00
|
|
|
|
console.log(`${new Date().toISOString()} | Entrance played for ${userId}: ${rel}`);
|
2025-08-10 23:18:43 +02:00
|
|
|
|
} catch (e) { console.warn('Entrance play error', e); }
|
2025-08-10 23:10:51 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-08-10 23:18:43 +02:00
|
|
|
|
}
|
2025-08-11 00:14:07 +02:00
|
|
|
|
// Exit: Nur wenn Nutzer wirklich auflegt (after ist leer). Bei Wechsel KEIN Exit-Sound.
|
|
|
|
|
|
if (before && !after) {
|
|
|
|
|
|
console.log(`${new Date().toISOString()} | Exit condition met (disconnect) for user=${userId} before=${before}`);
|
2025-08-10 23:10:51 +02:00
|
|
|
|
const mapping = persistedState.exitSounds ?? {};
|
|
|
|
|
|
const file = mapping[userId];
|
|
|
|
|
|
if (file) {
|
|
|
|
|
|
const rel = file.replace(/\\/g, '/');
|
|
|
|
|
|
const abs = path.join(SOUNDS_DIR, rel);
|
|
|
|
|
|
if (fs.existsSync(abs)) {
|
2025-08-10 23:18:43 +02:00
|
|
|
|
try {
|
|
|
|
|
|
await playFilePath(guildId, before, abs, undefined, rel);
|
2025-08-10 23:35:58 +02:00
|
|
|
|
console.log(`${new Date().toISOString()} | Exit played for ${userId}: ${rel}`);
|
2025-08-10 23:18:43 +02:00
|
|
|
|
} catch (e) { console.warn('Exit play error', e); }
|
2025-08-10 23:10:51 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-08-11 00:14:07 +02:00
|
|
|
|
} else if (before && after && before !== after) {
|
|
|
|
|
|
// Kanalwechsel: Exit-Sound unterdrücken
|
|
|
|
|
|
console.log(`${new Date().toISOString()} | Exit suppressed (move) for user=${userId} before=${before} after=${after}`);
|
2025-08-10 23:10:51 +02:00
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn('VoiceStateUpdate entrance/exit handling error', e);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-08-07 23:24:56 +02:00
|
|
|
|
client.on(Events.MessageCreate, async (message: Message) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (message.author?.bot) return;
|
2025-08-10 23:26:00 +02:00
|
|
|
|
// Commands überall annehmen (inkl. DMs)
|
2025-08-08 19:31:02 +02:00
|
|
|
|
const content = (message.content || '').trim();
|
|
|
|
|
|
if (content.startsWith('?')) {
|
|
|
|
|
|
await handleCommand(message, content);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Dateiuploads nur per DM
|
2025-08-07 23:24:56 +02:00
|
|
|
|
if (!message.channel?.isDMBased?.()) return;
|
|
|
|
|
|
if (message.attachments.size === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
for (const [, attachment] of message.attachments) {
|
2025-08-09 00:00:55 +02:00
|
|
|
|
const name = attachment.name ?? 'upload';
|
2025-08-07 23:24:56 +02:00
|
|
|
|
const lower = name.toLowerCase();
|
2025-08-09 00:00:55 +02:00
|
|
|
|
if (!(lower.endsWith('.mp3') || lower.endsWith('.wav'))) continue;
|
2025-08-07 23:24:56 +02:00
|
|
|
|
|
|
|
|
|
|
const safeName = name.replace(/[^a-zA-Z0-9_.-]/g, '_');
|
|
|
|
|
|
let targetPath = path.join(SOUNDS_DIR, safeName);
|
|
|
|
|
|
if (fs.existsSync(targetPath)) {
|
|
|
|
|
|
const base = path.parse(safeName).name;
|
2025-08-09 00:00:55 +02:00
|
|
|
|
const ext = path.parse(safeName).ext || (lower.endsWith('.wav') ? '.wav' : '.mp3');
|
2025-08-07 23:24:56 +02:00
|
|
|
|
let i = 2;
|
|
|
|
|
|
while (fs.existsSync(targetPath)) {
|
|
|
|
|
|
targetPath = path.join(SOUNDS_DIR, `${base}-${i}${ext}`);
|
|
|
|
|
|
i += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const res = await fetch(attachment.url);
|
|
|
|
|
|
if (!res.ok) throw new Error(`Download fehlgeschlagen: ${attachment.url}`);
|
|
|
|
|
|
const arrayBuffer = await res.arrayBuffer();
|
|
|
|
|
|
fs.writeFileSync(targetPath, Buffer.from(arrayBuffer));
|
2026-03-01 21:27:33 +01:00
|
|
|
|
// Sofort normalisieren für instant Play
|
|
|
|
|
|
if (NORMALIZE_ENABLE) {
|
|
|
|
|
|
normalizeToCache(targetPath).catch((e) => console.warn('Norm after upload failed:', e));
|
|
|
|
|
|
}
|
2025-08-08 19:31:02 +02:00
|
|
|
|
await message.author.send?.(`Sound gespeichert: ${path.basename(targetPath)}`);
|
2025-08-07 23:24:56 +02:00
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Fehler bei DM-Upload:', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await client.login(DISCORD_TOKEN);
|
|
|
|
|
|
|
|
|
|
|
|
// --- Express App ---
|
|
|
|
|
|
const app = express();
|
|
|
|
|
|
app.use(express.json());
|
|
|
|
|
|
app.use(cors());
|
|
|
|
|
|
|
|
|
|
|
|
app.get('/api/health', (_req: Request, res: Response) => {
|
2025-08-10 19:49:35 +02:00
|
|
|
|
res.json({ ok: true, totalPlays: persistedState.totalPlays ?? 0, categories: (persistedState.categories ?? []).length });
|
2025-08-07 23:24:56 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-01 18:56:37 +01:00
|
|
|
|
type ListedSound = {
|
|
|
|
|
|
fileName: string;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
folder: string;
|
|
|
|
|
|
relativePath: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function listAllSounds(): ListedSound[] {
|
|
|
|
|
|
const rootEntries = fs.readdirSync(SOUNDS_DIR, { withFileTypes: true });
|
|
|
|
|
|
const rootFiles: ListedSound[] = 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 folderItems: ListedSound[] = [];
|
2026-03-01 21:16:56 +01:00
|
|
|
|
const subFolders = rootEntries.filter((d) => d.isDirectory() && d.name !== '.norm-cache');
|
2026-03-01 18:56:37 +01:00
|
|
|
|
for (const dirent of subFolders) {
|
|
|
|
|
|
const folderName = dirent.name;
|
|
|
|
|
|
const folderPath = path.join(SOUNDS_DIR, folderName);
|
|
|
|
|
|
const entries = fs.readdirSync(folderPath, { withFileTypes: true });
|
|
|
|
|
|
for (const e of entries) {
|
|
|
|
|
|
if (!e.isFile()) continue;
|
|
|
|
|
|
const n = e.name.toLowerCase();
|
|
|
|
|
|
if (!(n.endsWith('.mp3') || n.endsWith('.wav'))) continue;
|
|
|
|
|
|
folderItems.push({
|
|
|
|
|
|
fileName: e.name,
|
|
|
|
|
|
name: path.parse(e.name).name,
|
|
|
|
|
|
folder: folderName,
|
|
|
|
|
|
relativePath: path.join(folderName, e.name),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return [...rootFiles, ...folderItems].sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
app.get('/api/analytics', (_req: Request, res: Response) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
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 item = byKey.get(rel);
|
|
|
|
|
|
if (!item) return null;
|
|
|
|
|
|
return {
|
|
|
|
|
|
name: item.name,
|
|
|
|
|
|
relativePath: item.relativePath,
|
|
|
|
|
|
count: Number(count) || 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
})
|
|
|
|
|
|
.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,
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
res.status(500).json({ error: e?.message ?? 'Analytics konnten nicht geladen werden' });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-08-08 14:23:18 +02:00
|
|
|
|
// --- Admin Auth ---
|
|
|
|
|
|
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(payload: AdminPayload): string {
|
|
|
|
|
|
const body = b64url(JSON.stringify(payload));
|
|
|
|
|
|
const sig = crypto.createHmac('sha256', ADMIN_PWD || 'no-admin').update(body).digest('base64url');
|
|
|
|
|
|
return `${body}.${sig}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
function verifyAdminToken(token: string | undefined): boolean {
|
|
|
|
|
|
if (!token || !ADMIN_PWD) return false;
|
|
|
|
|
|
const [body, sig] = token.split('.');
|
|
|
|
|
|
if (!body || !sig) return false;
|
|
|
|
|
|
const expected = crypto.createHmac('sha256', ADMIN_PWD).update(body).digest('base64url');
|
|
|
|
|
|
if (expected !== sig) return false;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const payload = JSON.parse(Buffer.from(body, 'base64').toString('utf8')) as AdminPayload;
|
|
|
|
|
|
if (typeof payload.exp !== 'number') return false;
|
|
|
|
|
|
return Date.now() < payload.exp;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
function readCookie(req: 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
function requireAdmin(req: Request, res: Response, next: () => void) {
|
|
|
|
|
|
if (!ADMIN_PWD) return res.status(503).json({ error: 'Admin nicht konfiguriert' });
|
|
|
|
|
|
const token = readCookie(req, 'admin');
|
|
|
|
|
|
if (!verifyAdminToken(token)) return res.status(401).json({ error: 'Nicht eingeloggt' });
|
|
|
|
|
|
next();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
app.post('/api/admin/login', (req: Request, res: Response) => {
|
|
|
|
|
|
if (!ADMIN_PWD) return res.status(503).json({ error: 'Admin nicht konfiguriert' });
|
|
|
|
|
|
const { password } = req.body as { password?: string };
|
|
|
|
|
|
if (!password || password !== ADMIN_PWD) return res.status(401).json({ error: 'Falsches Passwort' });
|
|
|
|
|
|
const token = signAdminToken({ 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/admin/logout', (_req: Request, res: Response) => {
|
|
|
|
|
|
res.setHeader('Set-Cookie', 'admin=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax');
|
|
|
|
|
|
res.json({ ok: true });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
app.get('/api/admin/status', (req: Request, res: Response) => {
|
|
|
|
|
|
res.json({ authenticated: verifyAdminToken(readCookie(req, 'admin')) });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-08-07 23:24:56 +02:00
|
|
|
|
app.get('/api/sounds', (req: Request, res: Response) => {
|
|
|
|
|
|
const q = String(req.query.q ?? '').toLowerCase();
|
2025-08-08 02:14:46 +02:00
|
|
|
|
const folderFilter = typeof req.query.folder === 'string' ? (req.query.folder as string) : '__all__';
|
2025-08-09 17:16:37 +02:00
|
|
|
|
const categoryFilter = typeof (req.query as any).categoryId === 'string' ? String((req.query as any).categoryId) : undefined;
|
2025-08-10 17:51:07 +02:00
|
|
|
|
const fuzzyParam = String((req.query as any).fuzzy ?? '0');
|
|
|
|
|
|
const useFuzzy = fuzzyParam === '1' || fuzzyParam === 'true';
|
2025-08-08 01:56:30 +02:00
|
|
|
|
|
2026-03-01 21:08:38 +01:00
|
|
|
|
const allItems = listAllSounds();
|
2025-08-08 01:56:30 +02:00
|
|
|
|
|
2026-03-01 21:08:38 +01:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2025-08-08 01:56:30 +02:00
|
|
|
|
const folders: Array<{ key: string; name: string; count: number }> = [];
|
2026-03-01 21:08:38 +01:00
|
|
|
|
for (const [key, count] of folderCounts) {
|
|
|
|
|
|
folders.push({ key, name: key, count });
|
2025-08-08 01:56:30 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-08 14:05:44 +02:00
|
|
|
|
// Zeitstempel für Neu-Logik
|
|
|
|
|
|
type ItemWithTime = { fileName: string; name: string; folder: string; relativePath: string; mtimeMs: number };
|
|
|
|
|
|
const allWithTime: ItemWithTime[] = [...allItems].map((it) => {
|
|
|
|
|
|
const stat = fs.statSync(path.join(SOUNDS_DIR, it.relativePath));
|
|
|
|
|
|
return { ...it, mtimeMs: stat.mtimeMs };
|
|
|
|
|
|
});
|
|
|
|
|
|
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));
|
2025-08-08 02:14:46 +02:00
|
|
|
|
let itemsByFolder = allItems;
|
|
|
|
|
|
if (folderFilter !== '__all__') {
|
2025-08-08 14:05:44 +02:00
|
|
|
|
if (folderFilter === '__recent__') {
|
|
|
|
|
|
itemsByFolder = recentTop10.map(({ fileName, name, folder, relativePath }) => ({ fileName, name, folder, relativePath }));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
itemsByFolder = allItems.filter((it) => (folderFilter === '' ? it.folder === '' : it.folder === folderFilter));
|
|
|
|
|
|
}
|
2025-08-08 02:14:46 +02:00
|
|
|
|
}
|
2025-08-10 02:59:25 +02:00
|
|
|
|
// Fuzzy-Score: bevorzugt Präfixe, zusammenhängende Treffer und frühe Positionen
|
|
|
|
|
|
function fuzzyScore(text: string, pattern: string): number {
|
|
|
|
|
|
if (!pattern) return 1;
|
|
|
|
|
|
if (text === pattern) return 2000;
|
|
|
|
|
|
const idx = text.indexOf(pattern);
|
|
|
|
|
|
if (idx !== -1) {
|
|
|
|
|
|
let base = 1000;
|
|
|
|
|
|
if (idx === 0) base += 200; // Präfix-Bonus
|
|
|
|
|
|
return base - idx * 2; // leichte Positionsstrafe
|
|
|
|
|
|
}
|
|
|
|
|
|
// subsequence Matching
|
|
|
|
|
|
let textIndex = 0;
|
|
|
|
|
|
let patIndex = 0;
|
|
|
|
|
|
let score = 0;
|
|
|
|
|
|
let lastMatch = -1;
|
|
|
|
|
|
let gaps = 0;
|
|
|
|
|
|
let firstMatchPos = -1;
|
|
|
|
|
|
while (textIndex < text.length && patIndex < pattern.length) {
|
|
|
|
|
|
if (text[textIndex] === pattern[patIndex]) {
|
|
|
|
|
|
if (firstMatchPos === -1) firstMatchPos = textIndex;
|
|
|
|
|
|
if (lastMatch === textIndex - 1) {
|
|
|
|
|
|
score += 5; // zusammenhängende Treffer belohnen
|
|
|
|
|
|
}
|
|
|
|
|
|
lastMatch = textIndex;
|
|
|
|
|
|
patIndex++;
|
|
|
|
|
|
} else if (firstMatchPos !== -1) {
|
|
|
|
|
|
gaps++;
|
|
|
|
|
|
}
|
|
|
|
|
|
textIndex++;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (patIndex !== pattern.length) return 0; // nicht alle Pattern-Zeichen gefunden
|
|
|
|
|
|
score += Math.max(0, 300 - firstMatchPos * 2); // frühe Starts belohnen
|
|
|
|
|
|
score += Math.max(0, 100 - gaps * 10); // weniger Lücken belohnen
|
|
|
|
|
|
return score;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let filteredItems = itemsByFolder;
|
|
|
|
|
|
if (q) {
|
2025-08-10 17:51:07 +02:00
|
|
|
|
if (useFuzzy) {
|
|
|
|
|
|
const scored = 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));
|
|
|
|
|
|
filteredItems = scored.map((x) => x.it);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
filteredItems = itemsByFolder.filter((s) => s.name.toLowerCase().includes(q));
|
|
|
|
|
|
}
|
2025-08-10 02:59:25 +02:00
|
|
|
|
}
|
2025-08-07 23:24:56 +02:00
|
|
|
|
|
2025-08-08 01:56:30 +02:00
|
|
|
|
const total = allItems.length;
|
2025-08-08 14:05:44 +02:00
|
|
|
|
const recentCount = Math.min(10, total);
|
2025-08-08 20:05:03 +02:00
|
|
|
|
// Nerdinfos: Top 3 meistgespielte
|
|
|
|
|
|
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 }>;
|
|
|
|
|
|
|
2025-08-08 14:05:44 +02:00
|
|
|
|
const foldersOut = [
|
|
|
|
|
|
{ key: '__all__', name: 'Alle', count: total },
|
2025-08-08 16:37:41 +02:00
|
|
|
|
{ key: '__recent__', name: 'Neu', count: recentCount },
|
2025-08-08 20:05:03 +02:00
|
|
|
|
...(top3.length ? [{ key: '__top3__', name: 'Most Played (3)', count: top3.length }] : []),
|
2025-08-08 14:05:44 +02:00
|
|
|
|
...folders
|
|
|
|
|
|
];
|
|
|
|
|
|
// isRecent-Flag für UI (Top 5 der neuesten)
|
2025-08-09 17:16:37 +02:00
|
|
|
|
// Kategorie-Filter (virtuell) anwenden, wenn gesetzt
|
2025-08-08 20:05:03 +02:00
|
|
|
|
let result = filteredItems;
|
2025-08-09 17:16:37 +02:00
|
|
|
|
if (categoryFilter) {
|
|
|
|
|
|
const fc = persistedState.fileCategories ?? {};
|
|
|
|
|
|
result = result.filter((it) => {
|
|
|
|
|
|
const key = it.relativePath ?? it.fileName;
|
|
|
|
|
|
const cats = fc[key] ?? [];
|
|
|
|
|
|
return cats.includes(categoryFilter);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-08-08 20:05:03 +02:00
|
|
|
|
if (folderFilter === '__top3__') {
|
|
|
|
|
|
const keys = new Set(top3.map(t => t.key.split(':')[1]));
|
|
|
|
|
|
result = allItems.filter(i => keys.has(i.relativePath ?? i.fileName));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-09 17:27:17 +02:00
|
|
|
|
// Badges vorbereiten (Top3 = Rakete, Recent = New)
|
|
|
|
|
|
const top3Set = new Set(top3.map(t => t.key.split(':')[1]));
|
|
|
|
|
|
const customBadges = persistedState.fileBadges ?? {};
|
|
|
|
|
|
const withRecentFlag = 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 } as any;
|
|
|
|
|
|
});
|
2025-08-08 14:05:44 +02:00
|
|
|
|
|
2025-08-09 17:16:37 +02:00
|
|
|
|
res.json({ items: withRecentFlag, total, folders: foldersOut, categories: persistedState.categories ?? [], fileCategories: persistedState.fileCategories ?? {} });
|
2025-08-07 23:24:56 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
2025-08-08 14:23:18 +02:00
|
|
|
|
// --- Admin: Bulk-Delete ---
|
|
|
|
|
|
app.post('/api/admin/sounds/delete', requireAdmin, (req: Request, res: Response) => {
|
|
|
|
|
|
const { paths } = req.body as { paths?: string[] };
|
|
|
|
|
|
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) {
|
2026-03-01 21:08:38 +01:00
|
|
|
|
const full = safeSoundsPath(rel);
|
|
|
|
|
|
if (!full) { results.push({ path: rel, ok: false, error: 'Ungültiger Pfad' }); continue; }
|
2025-08-08 14:23:18 +02:00
|
|
|
|
try {
|
|
|
|
|
|
if (fs.existsSync(full) && fs.statSync(full).isFile()) {
|
|
|
|
|
|
fs.unlinkSync(full);
|
2026-03-01 21:16:56 +01:00
|
|
|
|
// Loudnorm-Cache aufräumen
|
|
|
|
|
|
try { const cf = path.join(NORM_CACHE_DIR, normCacheKey(full)); if (fs.existsSync(cf)) fs.unlinkSync(cf); } catch {}
|
2025-08-08 14:23:18 +02:00
|
|
|
|
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 ?? 'Fehler' });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
res.json({ ok: true, results });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// --- Admin: Umbenennen einer Datei ---
|
|
|
|
|
|
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' });
|
2026-03-01 21:08:38 +01:00
|
|
|
|
const src = safeSoundsPath(from);
|
|
|
|
|
|
if (!src) return res.status(400).json({ error: 'Ungültiger Quellpfad' });
|
2025-08-08 14:23:18 +02:00
|
|
|
|
const parsed = path.parse(from);
|
2025-08-09 15:57:18 +02:00
|
|
|
|
// 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`);
|
2026-03-01 21:08:38 +01:00
|
|
|
|
const dst = safeSoundsPath(dstRel);
|
|
|
|
|
|
if (!dst) return res.status(400).json({ error: 'Ungültiger Zielpfad' });
|
2025-08-08 14:23:18 +02:00
|
|
|
|
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' });
|
|
|
|
|
|
fs.renameSync(src, dst);
|
2026-03-01 21:16:56 +01:00
|
|
|
|
// Loudnorm-Cache für alte Datei aufräumen (neue wird beim nächsten Play erzeugt)
|
|
|
|
|
|
try { const cf = path.join(NORM_CACHE_DIR, normCacheKey(src)); if (fs.existsSync(cf)) fs.unlinkSync(cf); } catch {}
|
2025-08-08 14:23:18 +02:00
|
|
|
|
res.json({ ok: true, from, to: dstRel });
|
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
res.status(500).json({ error: e?.message ?? 'Rename fehlgeschlagen' });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-08-09 17:16:37 +02:00
|
|
|
|
// --- Kategorien API ---
|
|
|
|
|
|
app.get('/api/categories', (_req: Request, res: Response) => {
|
|
|
|
|
|
res.json({ categories: persistedState.categories ?? [] });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
app.post('/api/categories', requireAdmin, (req: Request, res: Response) => {
|
|
|
|
|
|
const { name, color, sort } = req.body as { name?: string; color?: string; sort?: number };
|
|
|
|
|
|
const n = (name || '').trim();
|
|
|
|
|
|
if (!n) return res.status(400).json({ error: 'name erforderlich' });
|
|
|
|
|
|
const id = crypto.randomUUID();
|
|
|
|
|
|
const cat = { id, name: n, color, sort };
|
|
|
|
|
|
persistedState.categories = [...(persistedState.categories ?? []), cat];
|
|
|
|
|
|
writePersistedState(persistedState);
|
|
|
|
|
|
res.json({ ok: true, category: cat });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
app.patch('/api/categories/:id', requireAdmin, (req: Request, res: Response) => {
|
|
|
|
|
|
const { id } = req.params;
|
|
|
|
|
|
const { name, color, sort } = req.body as { name?: string; color?: string; sort?: number };
|
|
|
|
|
|
const cats = persistedState.categories ?? [];
|
|
|
|
|
|
const idx = cats.findIndex(c => c.id === id);
|
|
|
|
|
|
if (idx === -1) return res.status(404).json({ error: 'Kategorie nicht gefunden' });
|
|
|
|
|
|
const updated = { ...cats[idx] } as any;
|
|
|
|
|
|
if (typeof name === 'string') updated.name = name;
|
|
|
|
|
|
if (typeof color === 'string') updated.color = color;
|
|
|
|
|
|
if (typeof sort === 'number') updated.sort = sort;
|
|
|
|
|
|
cats[idx] = updated;
|
|
|
|
|
|
persistedState.categories = cats;
|
|
|
|
|
|
writePersistedState(persistedState);
|
|
|
|
|
|
res.json({ ok: true, category: updated });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
app.delete('/api/categories/:id', requireAdmin, (req: Request, res: Response) => {
|
|
|
|
|
|
const { id } = req.params;
|
|
|
|
|
|
const cats = persistedState.categories ?? [];
|
|
|
|
|
|
if (!cats.find(c => c.id === id)) return res.status(404).json({ error: 'Kategorie nicht gefunden' });
|
|
|
|
|
|
persistedState.categories = cats.filter(c => c.id !== id);
|
|
|
|
|
|
// Zuordnungen entfernen
|
|
|
|
|
|
const fc = persistedState.fileCategories ?? {};
|
|
|
|
|
|
for (const k of Object.keys(fc)) fc[k] = (fc[k] ?? []).filter(x => x !== id);
|
|
|
|
|
|
persistedState.fileCategories = fc;
|
|
|
|
|
|
writePersistedState(persistedState);
|
|
|
|
|
|
res.json({ ok: true });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Bulk-Assign/Remove Kategorien zu Dateien
|
|
|
|
|
|
app.post('/api/categories/assign', requireAdmin, (req: Request, res: Response) => {
|
|
|
|
|
|
const { files, add, remove } = req.body as { files?: string[]; add?: string[]; remove?: string[] };
|
|
|
|
|
|
if (!Array.isArray(files) || files.length === 0) return res.status(400).json({ error: 'files[] erforderlich' });
|
|
|
|
|
|
const validCats = new Set((persistedState.categories ?? []).map(c => c.id));
|
|
|
|
|
|
const toAdd = (add ?? []).filter(id => validCats.has(id));
|
|
|
|
|
|
const toRemove = (remove ?? []).filter(id => validCats.has(id));
|
|
|
|
|
|
const fc = persistedState.fileCategories ?? {};
|
|
|
|
|
|
for (const rel of files) {
|
|
|
|
|
|
const key = rel;
|
|
|
|
|
|
const old = new Set(fc[key] ?? []);
|
|
|
|
|
|
for (const a of toAdd) old.add(a);
|
|
|
|
|
|
for (const r of toRemove) old.delete(r);
|
|
|
|
|
|
fc[key] = Array.from(old);
|
|
|
|
|
|
}
|
|
|
|
|
|
persistedState.fileCategories = fc;
|
|
|
|
|
|
writePersistedState(persistedState);
|
|
|
|
|
|
res.json({ ok: true, fileCategories: fc });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-08-09 17:27:17 +02:00
|
|
|
|
// Badges (custom) setzen/entfernen (Rakete/Neu kommen automatisch, hier nur freie Badges)
|
|
|
|
|
|
app.post('/api/badges/assign', requireAdmin, (req: Request, res: Response) => {
|
|
|
|
|
|
const { files, add, remove } = req.body as { files?: string[]; add?: string[]; remove?: string[] };
|
|
|
|
|
|
if (!Array.isArray(files) || files.length === 0) return res.status(400).json({ error: 'files[] erforderlich' });
|
|
|
|
|
|
const fb = persistedState.fileBadges ?? {};
|
|
|
|
|
|
for (const rel of files) {
|
|
|
|
|
|
const key = rel;
|
|
|
|
|
|
const old = new Set(fb[key] ?? []);
|
|
|
|
|
|
for (const a of (add ?? [])) old.add(a);
|
|
|
|
|
|
for (const r of (remove ?? [])) old.delete(r);
|
|
|
|
|
|
fb[key] = Array.from(old);
|
|
|
|
|
|
}
|
|
|
|
|
|
persistedState.fileBadges = fb;
|
|
|
|
|
|
writePersistedState(persistedState);
|
|
|
|
|
|
res.json({ ok: true, fileBadges: fb });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-08-09 21:12:02 +02:00
|
|
|
|
// Alle Custom-Badges für die angegebenen Dateien entfernen
|
|
|
|
|
|
app.post('/api/badges/clear', requireAdmin, (req: Request, res: Response) => {
|
|
|
|
|
|
const { files } = req.body as { files?: string[] };
|
|
|
|
|
|
if (!Array.isArray(files) || files.length === 0) return res.status(400).json({ error: 'files[] erforderlich' });
|
|
|
|
|
|
const fb = persistedState.fileBadges ?? {};
|
|
|
|
|
|
for (const rel of files) {
|
|
|
|
|
|
delete fb[rel];
|
|
|
|
|
|
}
|
|
|
|
|
|
persistedState.fileBadges = fb;
|
|
|
|
|
|
writePersistedState(persistedState);
|
|
|
|
|
|
res.json({ ok: true, fileBadges: fb });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-08-07 23:24:56 +02:00
|
|
|
|
app.get('/api/channels', (_req: Request, res: Response) => {
|
|
|
|
|
|
if (!client.isReady()) return res.status(503).json({ error: 'Bot noch nicht bereit' });
|
|
|
|
|
|
|
|
|
|
|
|
const allowed = new Set(ALLOWED_GUILD_IDS);
|
2025-08-10 18:47:33 +02:00
|
|
|
|
const result: Array<{ guildId: string; guildName: string; channelId: string; channelName: string; selected?: boolean }> = [];
|
2025-08-07 23:24:56 +02:00
|
|
|
|
for (const [, guild] of client.guilds.cache) {
|
|
|
|
|
|
if (allowed.size > 0 && !allowed.has(guild.id)) continue;
|
|
|
|
|
|
const channels = guild.channels.cache;
|
|
|
|
|
|
for (const [, ch] of channels) {
|
|
|
|
|
|
if (ch?.type === ChannelType.GuildVoice || ch?.type === ChannelType.GuildStageVoice) {
|
2025-08-10 18:47:33 +02:00
|
|
|
|
const sel = getSelectedChannelForGuild(guild.id);
|
|
|
|
|
|
result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name, selected: sel === ch.id });
|
2025-08-07 23:24:56 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
result.sort((a, b) => a.guildName.localeCompare(b.guildName) || a.channelName.localeCompare(b.channelName));
|
|
|
|
|
|
res.json(result);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-08-10 18:47:33 +02:00
|
|
|
|
// Globale Channel-Auswahl: auslesen (komplettes Mapping)
|
|
|
|
|
|
app.get('/api/selected-channels', (_req: Request, res: Response) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
res.json({ selected: persistedState.selectedChannels ?? {} });
|
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Globale Channel-Auswahl: setzen (validiert Channel-Typ)
|
|
|
|
|
|
app.post('/api/selected-channel', async (req: Request, res: Response) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { guildId, channelId } = req.body as { guildId?: string; channelId?: string };
|
|
|
|
|
|
const gid = String(guildId ?? '');
|
|
|
|
|
|
const cid = String(channelId ?? '');
|
|
|
|
|
|
if (!gid || !cid) return res.status(400).json({ error: 'guildId und channelId erforderlich' });
|
|
|
|
|
|
const guild = client.guilds.cache.get(gid);
|
|
|
|
|
|
if (!guild) return res.status(404).json({ error: 'Guild nicht gefunden' });
|
|
|
|
|
|
const ch = guild.channels.cache.get(cid);
|
|
|
|
|
|
if (!ch || (ch.type !== ChannelType.GuildVoice && ch.type !== ChannelType.GuildStageVoice)) {
|
|
|
|
|
|
return res.status(400).json({ error: 'Ungültiger Voice-Channel' });
|
|
|
|
|
|
}
|
|
|
|
|
|
setSelectedChannelForGuild(gid, cid);
|
|
|
|
|
|
return res.json({ ok: true });
|
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
console.error('selected-channel error', e);
|
|
|
|
|
|
return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-08-07 23:24:56 +02:00
|
|
|
|
app.post('/api/play', async (req: Request, res: Response) => {
|
|
|
|
|
|
try {
|
2025-08-08 01:56:30 +02:00
|
|
|
|
const { soundName, guildId, channelId, volume, folder, relativePath } = req.body as {
|
2025-08-07 23:24:56 +02:00
|
|
|
|
soundName?: string;
|
|
|
|
|
|
guildId?: string;
|
|
|
|
|
|
channelId?: string;
|
2025-08-08 01:23:52 +02:00
|
|
|
|
volume?: number; // 0..1
|
2025-08-08 01:56:30 +02:00
|
|
|
|
folder?: string; // optional subfolder key
|
|
|
|
|
|
relativePath?: string; // optional direct relative path
|
2025-08-07 23:24:56 +02:00
|
|
|
|
};
|
|
|
|
|
|
if (!soundName || !guildId || !channelId) return res.status(400).json({ error: 'soundName, guildId, channelId erforderlich' });
|
|
|
|
|
|
|
2025-08-08 01:56:30 +02:00
|
|
|
|
let filePath: string;
|
|
|
|
|
|
if (relativePath) filePath = path.join(SOUNDS_DIR, relativePath);
|
2025-08-09 00:00:55 +02:00
|
|
|
|
else if (folder) {
|
|
|
|
|
|
const mp3 = path.join(SOUNDS_DIR, folder, `${soundName}.mp3`);
|
|
|
|
|
|
const wav = path.join(SOUNDS_DIR, folder, `${soundName}.wav`);
|
|
|
|
|
|
filePath = fs.existsSync(mp3) ? mp3 : wav;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const mp3 = path.join(SOUNDS_DIR, `${soundName}.mp3`);
|
|
|
|
|
|
const wav = path.join(SOUNDS_DIR, `${soundName}.wav`);
|
|
|
|
|
|
filePath = fs.existsSync(mp3) ? mp3 : wav;
|
|
|
|
|
|
}
|
2025-08-07 23:24:56 +02:00
|
|
|
|
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Sound nicht gefunden' });
|
|
|
|
|
|
|
2026-03-01 21:08:38 +01:00
|
|
|
|
// Delegiere an playFilePath (inkl. Loudnorm, Connection-Management, Now-Playing Broadcast)
|
|
|
|
|
|
const relKey = relativePath || (folder ? `${folder}/${soundName}` : soundName);
|
|
|
|
|
|
await playFilePath(guildId, channelId, filePath, volume, relKey!);
|
2025-08-07 23:24:56 +02:00
|
|
|
|
return res.json({ ok: true });
|
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
|
console.error('Play-Fehler:', err);
|
|
|
|
|
|
return res.status(500).json({ error: err?.message ?? 'Unbekannter Fehler' });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-08-08 01:51:36 +02:00
|
|
|
|
// Lautstärke zur Laufzeit ändern (0..1). Wirken sofort auf aktuelle Resource, sonst als Default für nächste Wiedergabe.
|
|
|
|
|
|
app.post('/api/volume', (req: Request, res: Response) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { guildId, volume } = req.body as { guildId?: string; volume?: number };
|
|
|
|
|
|
if (!guildId || typeof volume !== 'number' || !Number.isFinite(volume)) {
|
|
|
|
|
|
return res.status(400).json({ error: 'guildId und volume (0..1) erforderlich' });
|
|
|
|
|
|
}
|
|
|
|
|
|
const safeVolume = Math.max(0, Math.min(1, volume));
|
|
|
|
|
|
const state = guildAudioState.get(guildId);
|
|
|
|
|
|
if (!state) {
|
2025-08-08 13:46:27 +02:00
|
|
|
|
// Kein aktiver Player: nur persistieren für nächste Wiedergabe
|
|
|
|
|
|
persistedState.volumes[guildId] = safeVolume;
|
|
|
|
|
|
writePersistedState(persistedState);
|
2025-08-10 21:15:39 +02:00
|
|
|
|
// Broadcast neue Lautstärke an alle Clients
|
|
|
|
|
|
sseBroadcast({ type: 'volume', guildId, volume: safeVolume });
|
2025-08-08 13:46:27 +02:00
|
|
|
|
return res.json({ ok: true, volume: safeVolume, persistedOnly: true });
|
2025-08-08 01:51:36 +02:00
|
|
|
|
}
|
|
|
|
|
|
state.currentVolume = safeVolume;
|
|
|
|
|
|
if (state.currentResource?.volume) {
|
|
|
|
|
|
state.currentResource.volume.setVolume(safeVolume);
|
|
|
|
|
|
console.log(`${new Date().toISOString()} | live setVolume(${safeVolume}) guild=${guildId}`);
|
|
|
|
|
|
}
|
2025-08-08 13:46:27 +02:00
|
|
|
|
persistedState.volumes[guildId] = safeVolume;
|
|
|
|
|
|
writePersistedState(persistedState);
|
2025-08-10 21:15:39 +02:00
|
|
|
|
// Broadcast neue Lautstärke an alle Clients
|
|
|
|
|
|
sseBroadcast({ type: 'volume', guildId, volume: safeVolume });
|
2025-08-08 01:51:36 +02:00
|
|
|
|
return res.json({ ok: true, volume: safeVolume });
|
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
console.error('Volume-Fehler:', e);
|
|
|
|
|
|
return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-08-08 13:46:27 +02:00
|
|
|
|
// Aktuelle/gespeicherte Lautstärke abrufen
|
|
|
|
|
|
app.get('/api/volume', (req: Request, res: Response) => {
|
|
|
|
|
|
const guildId = String(req.query.guildId ?? '');
|
|
|
|
|
|
if (!guildId) return res.status(400).json({ error: 'guildId erforderlich' });
|
|
|
|
|
|
const state = guildAudioState.get(guildId);
|
|
|
|
|
|
const v = state?.currentVolume ?? getPersistedVolume(guildId);
|
|
|
|
|
|
return res.json({ volume: v });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-08-08 18:40:40 +02:00
|
|
|
|
// Panik: Stoppe aktuelle Wiedergabe sofort
|
|
|
|
|
|
app.post('/api/stop', (req: Request, res: Response) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const guildId = String((req.query.guildId || (req.body as any)?.guildId) ?? '');
|
|
|
|
|
|
if (!guildId) return res.status(400).json({ error: 'guildId erforderlich' });
|
|
|
|
|
|
const state = guildAudioState.get(guildId);
|
|
|
|
|
|
if (!state) return res.status(404).json({ error: 'Kein aktiver Player' });
|
|
|
|
|
|
state.player.stop(true);
|
2026-03-01 16:00:22 +01:00
|
|
|
|
// Now-Playing löschen
|
|
|
|
|
|
nowPlaying.delete(guildId);
|
|
|
|
|
|
sseBroadcast({ type: 'nowplaying', guildId, name: '' });
|
2025-08-09 22:43:13 +02:00
|
|
|
|
// Partymode für diese Guild ebenfalls stoppen
|
|
|
|
|
|
try {
|
|
|
|
|
|
const t = partyTimers.get(guildId);
|
|
|
|
|
|
if (t) clearTimeout(t);
|
|
|
|
|
|
partyTimers.delete(guildId);
|
|
|
|
|
|
partyActive.delete(guildId);
|
2025-08-09 23:20:13 +02:00
|
|
|
|
sseBroadcast({ type: 'party', guildId, active: false });
|
2025-08-09 22:43:13 +02:00
|
|
|
|
} catch {}
|
|
|
|
|
|
return res.json({ ok: true });
|
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// --- Partymode (serverseitig) ---
|
|
|
|
|
|
function schedulePartyPlayback(guildId: string, channelId: string) {
|
|
|
|
|
|
const MIN_DELAY = 30_000; // 30s
|
|
|
|
|
|
const MAX_EXTRA = 60_000; // +0..60s => 30..90s
|
|
|
|
|
|
|
|
|
|
|
|
const doPlay = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// Dateien ermitteln (mp3/wav, inkl. Subfolder)
|
|
|
|
|
|
const rootEntries = fs.readdirSync(SOUNDS_DIR, { withFileTypes: true });
|
|
|
|
|
|
const pick: string[] = [];
|
|
|
|
|
|
for (const d of rootEntries) {
|
|
|
|
|
|
if (d.isFile()) {
|
|
|
|
|
|
const l = d.name.toLowerCase(); if (l.endsWith('.mp3') || l.endsWith('.wav')) pick.push(path.join(SOUNDS_DIR, d.name));
|
|
|
|
|
|
} else if (d.isDirectory()) {
|
|
|
|
|
|
const folderPath = path.join(SOUNDS_DIR, d.name);
|
|
|
|
|
|
const entries = fs.readdirSync(folderPath, { withFileTypes: true });
|
|
|
|
|
|
for (const e of entries) {
|
|
|
|
|
|
if (!e.isFile()) continue;
|
|
|
|
|
|
const n = e.name.toLowerCase();
|
|
|
|
|
|
if (n.endsWith('.mp3') || n.endsWith('.wav')) pick.push(path.join(folderPath, e.name));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (pick.length === 0) return;
|
|
|
|
|
|
const filePath = pick[Math.floor(Math.random() * pick.length)];
|
|
|
|
|
|
await playFilePath(guildId, channelId, filePath);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Partymode play error:', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const loop = async () => {
|
|
|
|
|
|
if (!partyActive.has(guildId)) return;
|
|
|
|
|
|
await doPlay();
|
|
|
|
|
|
if (!partyActive.has(guildId)) return;
|
|
|
|
|
|
const delay = MIN_DELAY + Math.floor(Math.random() * MAX_EXTRA);
|
|
|
|
|
|
const t = setTimeout(loop, delay);
|
|
|
|
|
|
partyTimers.set(guildId, t);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Start: sofort spielen und nächste planen
|
|
|
|
|
|
partyActive.add(guildId);
|
|
|
|
|
|
void loop();
|
2025-08-09 23:20:13 +02:00
|
|
|
|
// Broadcast Status
|
|
|
|
|
|
sseBroadcast({ type: 'party', guildId, active: true, channelId });
|
2025-08-09 22:43:13 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
app.post('/api/party/start', async (req: Request, res: Response) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { guildId, channelId } = req.body as { guildId?: string; channelId?: string };
|
|
|
|
|
|
if (!guildId || !channelId) return res.status(400).json({ error: 'guildId und channelId erforderlich' });
|
|
|
|
|
|
// vorhandenen Timer stoppen
|
|
|
|
|
|
const old = partyTimers.get(guildId); if (old) clearTimeout(old);
|
|
|
|
|
|
partyTimers.delete(guildId);
|
|
|
|
|
|
schedulePartyPlayback(guildId, channelId);
|
|
|
|
|
|
return res.json({ ok: true });
|
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
console.error('party/start error', e);
|
|
|
|
|
|
return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
app.post('/api/party/stop', (req: Request, res: Response) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { guildId } = req.body as { guildId?: string };
|
|
|
|
|
|
const id = String(guildId ?? '');
|
|
|
|
|
|
if (!id) return res.status(400).json({ error: 'guildId erforderlich' });
|
|
|
|
|
|
const t = partyTimers.get(id); if (t) clearTimeout(t);
|
|
|
|
|
|
partyTimers.delete(id);
|
|
|
|
|
|
partyActive.delete(id);
|
2025-08-09 23:20:13 +02:00
|
|
|
|
sseBroadcast({ type: 'party', guildId: id, active: false });
|
2025-08-08 18:40:40 +02:00
|
|
|
|
return res.json({ ok: true });
|
|
|
|
|
|
} catch (e: any) {
|
2025-08-09 22:43:13 +02:00
|
|
|
|
console.error('party/stop error', e);
|
2025-08-08 18:40:40 +02:00
|
|
|
|
return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-08-09 23:20:13 +02:00
|
|
|
|
// Server-Sent Events Endpoint
|
|
|
|
|
|
app.get('/api/events', (req: Request, res: Response) => {
|
|
|
|
|
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
|
|
|
|
res.setHeader('Cache-Control', 'no-cache');
|
|
|
|
|
|
res.setHeader('Connection', 'keep-alive');
|
|
|
|
|
|
res.flushHeaders?.();
|
|
|
|
|
|
|
|
|
|
|
|
// Snapshot senden
|
2025-08-10 18:47:33 +02:00
|
|
|
|
try {
|
2026-03-01 16:00:22 +01:00
|
|
|
|
res.write(`data: ${JSON.stringify({ type: 'snapshot', party: Array.from(partyActive), selected: persistedState.selectedChannels ?? {}, volumes: persistedState.volumes ?? {}, nowplaying: Object.fromEntries(nowPlaying) })}\n\n`);
|
2025-08-10 18:47:33 +02:00
|
|
|
|
} catch {}
|
2025-08-09 23:20:13 +02:00
|
|
|
|
|
2025-08-10 00:11:38 +02:00
|
|
|
|
// Ping, damit Proxies die Verbindung offen halten
|
|
|
|
|
|
const ping = setInterval(() => { try { res.write(':\n\n'); } catch {} }, 15_000);
|
|
|
|
|
|
|
2025-08-09 23:20:13 +02:00
|
|
|
|
sseClients.add(res);
|
|
|
|
|
|
req.on('close', () => {
|
|
|
|
|
|
sseClients.delete(res);
|
2025-08-10 00:11:38 +02:00
|
|
|
|
clearInterval(ping);
|
2025-08-09 23:20:13 +02:00
|
|
|
|
try { res.end(); } catch {}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-08-08 15:22:15 +02:00
|
|
|
|
// --- Medien-URL abspielen ---
|
2026-03-01 18:56:37 +01:00
|
|
|
|
// Unterstützt: direkte MP3-URL (Download und Ablage)
|
2025-08-08 15:22:15 +02:00
|
|
|
|
app.post('/api/play-url', async (req: Request, res: Response) => {
|
|
|
|
|
|
try {
|
2025-08-08 18:31:15 +02:00
|
|
|
|
const { url, guildId, channelId, volume } = req.body as { url?: string; guildId?: string; channelId?: string; volume?: number };
|
2025-08-08 15:22:15 +02:00
|
|
|
|
if (!url || !guildId || !channelId) return res.status(400).json({ error: 'url, guildId, channelId erforderlich' });
|
|
|
|
|
|
|
2026-03-01 18:56:37 +01:00
|
|
|
|
let parsed: URL;
|
|
|
|
|
|
try {
|
|
|
|
|
|
parsed = new URL(url);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return res.status(400).json({ error: 'Ungültige URL' });
|
|
|
|
|
|
}
|
|
|
|
|
|
const pathname = parsed.pathname.toLowerCase();
|
|
|
|
|
|
if (!pathname.endsWith('.mp3')) {
|
|
|
|
|
|
return res.status(400).json({ error: 'Nur direkte MP3-Links werden unterstützt.' });
|
|
|
|
|
|
}
|
|
|
|
|
|
const fileName = path.basename(parsed.pathname);
|
|
|
|
|
|
const dest = path.join(SOUNDS_DIR, fileName);
|
|
|
|
|
|
const r = await fetch(url);
|
|
|
|
|
|
if (!r.ok) return res.status(400).json({ error: 'Download fehlgeschlagen' });
|
|
|
|
|
|
const buf = Buffer.from(await r.arrayBuffer());
|
|
|
|
|
|
fs.writeFileSync(dest, buf);
|
2026-03-01 21:27:33 +01:00
|
|
|
|
// Vor dem Abspielen normalisieren → sofort aus Cache
|
|
|
|
|
|
if (NORMALIZE_ENABLE) {
|
|
|
|
|
|
try { await normalizeToCache(dest); } catch (e) { console.warn('Norm after URL import failed:', e); }
|
|
|
|
|
|
}
|
2026-03-01 18:56:37 +01:00
|
|
|
|
try {
|
|
|
|
|
|
await playFilePath(guildId, channelId, dest, volume, path.basename(dest));
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return res.status(500).json({ error: 'Abspielen fehlgeschlagen' });
|
2025-08-08 15:22:15 +02:00
|
|
|
|
}
|
2026-03-01 18:56:37 +01:00
|
|
|
|
return res.json({ ok: true, saved: path.basename(dest) });
|
2025-08-08 15:22:15 +02:00
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
console.error('play-url error:', e);
|
|
|
|
|
|
return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-01 21:08:38 +01:00
|
|
|
|
// 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}`);
|
2026-03-01 21:16:56 +01:00
|
|
|
|
|
2026-03-01 21:27:33 +01:00
|
|
|
|
// Vollständige Cache-Synchronisation beim Start (Hintergrund)
|
|
|
|
|
|
syncNormCache();
|
2026-03-01 21:08:38 +01:00
|
|
|
|
});
|
2025-08-10 01:47:17 +02:00
|
|
|
|
|
2025-08-07 23:24:56 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|