Merge branch 'feature/nightly' into main

This commit is contained in:
Bot 2026-03-02 00:05:17 +01:00
commit df58b5cc93
12 changed files with 7516 additions and 1553 deletions

View file

@ -31,12 +31,16 @@ jobs:
echo "tag=main" >> $GITHUB_OUTPUT
echo "version=1.1.0" >> $GITHUB_OUTPUT
echo "channel=stable" >> $GITHUB_OUTPUT
elif [[ "${{ github.ref_name }}" == "feature/nightly" ]] || [[ "${{ github.ref_name }}" == "nightly" ]]; then
echo "tag=nightly" >> $GITHUB_OUTPUT
echo "version=1.1.0-nightly" >> $GITHUB_OUTPUT
echo "channel=nightly" >> $GITHUB_OUTPUT
else
# Ersetze Slashes durch Bindestriche für gültige Docker Tags
CLEAN_TAG=$(echo "${{ github.ref_name }}" | sed 's/\//-/g')
echo "tag=$CLEAN_TAG" >> $GITHUB_OUTPUT
echo "version=1.1.0-nightly" >> $GITHUB_OUTPUT
echo "channel=nightly" >> $GITHUB_OUTPUT
echo "version=1.1.0-dev" >> $GITHUB_OUTPUT
echo "channel=dev" >> $GITHUB_OUTPUT
fi
# Nur auf main: auch :latest tag pushen

View file

@ -5,6 +5,9 @@ variables:
IMAGE_NAME: $DOCKERHUB_USERNAME/discordsoundbot-vib
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: ""
# Force clone via IP instead of hostname to bypass Unraid Docker DNS issues
CI_SERVER_URL: "http://10.10.10.10:9080"
GITLAB_FEATURES: ""
docker-build:
stage: build
@ -31,9 +34,9 @@ docker-build:
export VERSION="1.1.0-dev"
export CHANNEL="dev"
fi
- echo "Building for channel $CHANNEL with version $VERSION and tag $TAG"
# Build
- docker pull $IMAGE_NAME:$TAG || true
- >

View file

@ -21,8 +21,8 @@ COPY server/package*.json ./
RUN npm install --no-audit --no-fund
COPY server/ .
RUN npm run build
# Nur Prod-Dependencies für Runtime behalten
RUN npm prune --omit=dev
# Nur Prod-Dependencies für Runtime behalten. rm -rf and cleanly install to prevent npm prune bugs
RUN rm -rf node_modules && npm install --omit=dev --no-audit --no-fund
# --- Runtime image ---
FROM node:20-slim AS runtime

2384
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -10,24 +10,23 @@
"start": "node dist/index.js"
},
"dependencies": {
"@discordjs/voice": "^0.18.0",
"@discordjs/opus": "^0.9.0",
"@discordjs/voice": "^0.18.0",
"cors": "^2.8.5",
"discord.js": "^14.16.3",
"express": "^4.19.2",
"libsodium-wrappers": "^0.7.13",
"tweetnacl": "^1.0.3",
"sodium-native": "^4.0.8",
"cors": "^2.8.5",
"libsodium-wrappers": "^0.8.2",
"multer": "^2.0.0",
"sodium-native": "^4.0.8",
"tweetnacl": "^1.0.3",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/multer": "^1.4.12",
"@types/node": "^20.12.12",
"ts-node": "^10.9.2",
"typescript": "^5.5.4"
}
}

View file

@ -2,7 +2,7 @@ import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import express, { Request, Response } from 'express';
// import multer from 'multer';
import multer from 'multer';
import cors from 'cors';
import crypto from 'node:crypto';
import { Client, GatewayIntentBits, Partials, ChannelType, Events, type Message, VoiceState } from 'discord.js';
@ -24,6 +24,7 @@ import sodium from 'libsodium-wrappers';
import nacl from 'tweetnacl';
// Streaming externer Plattformen entfernt nur MP3-URLs werden noch unterstützt
import child_process from 'node:child_process';
import { PassThrough } from 'node:stream';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@ -116,16 +117,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 {}
}
@ -135,6 +154,112 @@ 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');
// 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) => {
const ffArgs = ['-hide_banner', '-loglevel', 'error', '-y', '-i', filePath,
'-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}`));
});
});
}
// Wie viele ffmpeg-Prozesse parallel beim Cache-Sync laufen dürfen.
// 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));
/**
* Vollständige Cache-Synchronisation:
* 1. Alle Sound-Dateien normalisieren, die noch nicht im Cache sind (parallel)
* 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>();
const toProcess: string[] = [];
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;
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);
}
}
}
const workers = Array.from({ length: Math.min(NORM_CONCURRENCY, toProcess.length || 1) }, worker);
await Promise.all(workers);
// 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(
`Norm-Cache sync (${dt}s, ${NORM_CONCURRENCY} parallel): ${created} neu, ${skipped} vorhanden, ${failed} fehlgeschlagen, ${cleaned} verwaist entfernt (${allSounds.length} Sounds gesamt)`
);
}
// --- Voice Abhängigkeiten prüfen ---
await sodium.ready;
// init nacl to ensure it loads
@ -164,6 +289,8 @@ const guildAudioState = new Map<string, GuildAudioState>();
// Partymode: serverseitige Steuerung (global pro Guild)
const partyTimers = new Map<string, NodeJS.Timeout>();
const partyActive = new Set<string>();
// Now-Playing: aktuell gespielter Sound pro Guild
const nowPlaying = new Map<string, string>();
// SSE-Klienten für Broadcasts (z.B. Partymode Status)
const sseClients = new Set<Response>();
function sseBroadcast(payload: any) {
@ -252,11 +379,36 @@ async function playFilePath(guildId: string, channelId: string, filePath: string
: (state.currentVolume ?? 1);
let resource: AudioResource;
if (NORMALIZE_ENABLE) {
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);
resource = createAudioResource(ff.stdout as any, { inlineVolume: true, inputType: StreamType.Raw });
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();
console.log(`${new Date().toISOString()} | Loudnorm cached: ${path.basename(filePath)}`);
});
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 });
}
} else {
resource = createAudioResource(filePath, { inlineVolume: true });
}
@ -265,6 +417,10 @@ async function playFilePath(guildId: string, channelId: string, filePath: string
state.player.play(resource);
state.currentResource = resource;
state.currentVolume = useVolume;
// Now-Playing broadcast
const soundLabel = relativeKey ? path.parse(relativeKey).name : path.parse(filePath).name;
nowPlaying.set(guildId, soundLabel);
sseBroadcast({ type: 'nowplaying', guildId, name: soundLabel });
if (relativeKey) incrementPlaysFor(relativeKey);
}
@ -533,6 +689,10 @@ client.on(Events.MessageCreate, async (message: Message) => {
if (!res.ok) throw new Error(`Download fehlgeschlagen: ${attachment.url}`);
const arrayBuffer = await res.arrayBuffer();
fs.writeFileSync(targetPath, Buffer.from(arrayBuffer));
// Sofort normalisieren für instant Play
if (NORMALIZE_ENABLE) {
normalizeToCache(targetPath).catch((e) => console.warn('Norm after upload failed:', e));
}
await message.author.send?.(`Sound gespeichert: ${path.basename(targetPath)}`);
}
} catch (err) {
@ -551,6 +711,83 @@ app.get('/api/health', (_req: Request, res: Response) => {
res.json({ ok: true, totalPlays: persistedState.totalPlays ?? 0, categories: (persistedState.categories ?? []).length });
});
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[] = [];
const subFolders = rootEntries.filter((d) => d.isDirectory() && d.name !== '.norm-cache');
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' });
}
});
// --- Admin Auth ---
type AdminPayload = { iat: number; exp: number };
function b64url(input: Buffer | string): string {
@ -616,40 +853,17 @@ app.get('/api/sounds', (req: Request, res: Response) => {
const fuzzyParam = String((req.query as any).fuzzy ?? '0');
const useFuzzy = fuzzyParam === '1' || fuzzyParam === 'true';
const rootEntries = fs.readdirSync(SOUNDS_DIR, { withFileTypes: true });
const rootFiles = rootEntries
.filter((d) => {
if (!d.isFile()) return false;
const n = d.name.toLowerCase();
return n.endsWith('.mp3') || n.endsWith('.wav');
})
.map((d) => ({ fileName: d.name, name: path.parse(d.name).name, folder: '', relativePath: d.name }));
const allItems = listAllSounds();
const folders: Array<{ key: string; name: string; count: number }> = [];
const subFolders = rootEntries.filter((d) => d.isDirectory());
const folderItems: Array<{ fileName: string; name: string; folder: string; relativePath: string }> = [];
for (const dirent of subFolders) {
const folderName = dirent.name;
const folderPath = path.join(SOUNDS_DIR, folderName);
const entries = fs.readdirSync(folderPath, { withFileTypes: true });
const audios = entries.filter((e) => {
if (!e.isFile()) return false;
const n = e.name.toLowerCase();
return n.endsWith('.mp3') || n.endsWith('.wav');
});
for (const f of audios) {
folderItems.push({
fileName: f.name,
name: path.parse(f.name).name,
folder: folderName,
relativePath: path.join(folderName, f.name)
});
}
folders.push({ key: folderName, name: folderName, count: audios.length });
// Ordner-Statistik aus allItems ableiten
const folderCounts = new Map<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 }> = [];
for (const [key, count] of folderCounts) {
folders.push({ key, name: key, count });
}
const allItems = [...rootFiles, ...folderItems].sort((a, b) => a.name.localeCompare(b.name));
// Zeitstempel für Neu-Logik
type ItemWithTime = { fileName: string; name: string; folder: string; relativePath: string; mtimeMs: number };
@ -773,10 +987,13 @@ 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);
// Loudnorm-Cache aufräumen
try { const cf = path.join(NORM_CACHE_DIR, normCacheKey(full)); if (fs.existsSync(cf)) fs.unlinkSync(cf); } catch {}
results.push({ path: rel, ok: true });
} else {
results.push({ path: rel, ok: false, error: 'nicht gefunden' });
@ -792,23 +1009,69 @@ 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' });
fs.renameSync(src, dst);
// 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 {}
res.json({ ok: true, from, to: dstRel });
} catch (e: any) {
res.status(500).json({ error: e?.message ?? 'Rename fehlgeschlagen' });
}
});
// --- Datei-Upload (Drag & Drop) ---
type MulterFile = { fieldname: string; originalname: string; encoding: string; mimetype: string; destination: string; filename: string; path: string; size: number; };
const uploadStorage = multer.diskStorage({
destination: (_req: any, _file: any, cb: (e: null, dest: string) => void) => cb(null, SOUNDS_DIR),
filename: (_req: any, file: { originalname: string }, cb: (e: null, name: string) => void) => {
const safe = file.originalname.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
const { name, ext } = path.parse(safe);
let finalName = safe;
let i = 2;
while (fs.existsSync(path.join(SOUNDS_DIR, finalName))) {
finalName = `${name}-${i}${ext}`;
i++;
}
cb(null, finalName);
},
});
const uploadMulter = multer({
storage: uploadStorage,
fileFilter: (_req: any, file: { originalname: string }, cb: (e: null, ok: boolean) => void) => {
const ext = path.extname(file.originalname).toLowerCase();
cb(null, ext === '.mp3' || ext === '.wav');
},
limits: { fileSize: 50 * 1024 * 1024, files: 20 },
});
app.post('/api/upload', requireAdmin, (req: Request, res: Response) => {
uploadMulter.array('files', 20)(req, res, async (err: any) => {
if (err) return res.status(400).json({ error: err?.message ?? 'Upload fehlgeschlagen' });
const files = (req as any).files as MulterFile[] | undefined;
if (!files?.length) return res.status(400).json({ error: 'Keine gültigen Dateien (nur MP3/WAV)' });
const saved = files.map(f => ({ name: f.filename, size: f.size }));
// Normalisierung im Hintergrund starten
if (NORMALIZE_ENABLE) {
for (const f of files) {
normalizeToCache(f.path).catch(e => console.warn(`Norm after upload failed (${f.filename}):`, e));
}
}
console.log(`${new Date().toISOString()} | Upload: ${files.map(f => f.filename).join(', ')}`);
res.json({ ok: true, files: saved });
});
});
// --- Kategorien API ---
app.get('/api/categories', (_req: Request, res: Response) => {
res.json({ categories: persistedState.categories ?? [] });
@ -968,7 +1231,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;
@ -979,109 +1241,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}`);
// 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);
@ -1139,6 +1301,9 @@ app.post('/api/stop', (req: Request, res: Response) => {
const state = guildAudioState.get(guildId);
if (!state) return res.status(404).json({ error: 'Kein aktiver Player' });
state.player.stop(true);
// Now-Playing löschen
nowPlaying.delete(guildId);
sseBroadcast({ type: 'nowplaying', guildId, name: '' });
// Partymode für diese Guild ebenfalls stoppen
try {
const t = partyTimers.get(guildId);
@ -1240,7 +1405,7 @@ app.get('/api/events', (req: Request, res: Response) => {
// Snapshot senden
try {
res.write(`data: ${JSON.stringify({ type: 'snapshot', party: Array.from(partyActive), selected: persistedState.selectedChannels ?? {}, volumes: persistedState.volumes ?? {} })}\n\n`);
res.write(`data: ${JSON.stringify({ type: 'snapshot', party: Array.from(partyActive), selected: persistedState.selectedChannels ?? {}, volumes: persistedState.volumes ?? {}, nowplaying: Object.fromEntries(nowPlaying) })}\n\n`);
} catch {}
// Ping, damit Proxies die Verbindung offen halten
@ -1254,6 +1419,45 @@ app.get('/api/events', (req: Request, res: Response) => {
});
});
// --- Medien-URL abspielen ---
// Unterstützt: direkte MP3-URL (Download und Ablage)
app.post('/api/play-url', async (req: Request, res: Response) => {
try {
const { url, guildId, channelId, volume } = req.body as { url?: string; guildId?: string; channelId?: string; volume?: number };
if (!url || !guildId || !channelId) return res.status(400).json({ error: 'url, guildId, channelId erforderlich' });
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);
// Vor dem Abspielen normalisieren → sofort aus Cache
if (NORMALIZE_ENABLE) {
try { await normalizeToCache(dest); } catch (e) { console.warn('Norm after URL import failed:', e); }
}
try {
await playFilePath(guildId, channelId, dest, volume, path.basename(dest));
} catch {
return res.status(500).json({ error: 'Abspielen fehlgeschlagen' });
}
return res.json({ ok: true, saved: path.basename(dest) });
} catch (e: any) {
console.error('play-url error:', e);
return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' });
}
});
// Static Frontend ausliefern (Vite build)
const webDistPath = path.resolve(__dirname, '../../web/dist');
if (fs.existsSync(webDistPath)) {
@ -1265,39 +1469,11 @@ if (fs.existsSync(webDistPath)) {
app.listen(PORT, () => {
console.log(`Server läuft auf http://0.0.0.0:${PORT}`);
// Vollständige Cache-Synchronisation beim Start (Hintergrund)
syncNormCache();
});
// --- Medien-URL abspielen ---
// Unterstützt: direkte MP3- oder WAV-URL (Download und Ablage)
app.post('/api/play-url', async (req: Request, res: Response) => {
try {
const { url, guildId, channelId, volume } = req.body as { url?: string; guildId?: string; channelId?: string; volume?: number };
if (!url || !guildId || !channelId) return res.status(400).json({ error: 'url, guildId, channelId erforderlich' });
const lower = url.toLowerCase();
if (lower.endsWith('.mp3') || lower.endsWith('.wav')) {
const fileName = path.basename(new URL(url).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);
try {
await playFilePath(guildId, channelId, dest, volume, path.basename(dest));
} catch {
return res.status(500).json({ error: 'Abspielen fehlgeschlagen' });
}
return res.json({ ok: true, saved: path.basename(dest) });
}
return res.status(400).json({ error: 'Nur MP3- oder WAV-Links werden unterstützt.' });
} catch (e: any) {
console.error('play-url error:', e);
return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' });
}
});
// Upload endpoint removed (build reverted)

View file

@ -1,21 +1,20 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Soundboard</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<script type="module" src="/src/main.tsx"></script>
</head>
<body class="p-4 sm:p-8">
<div id="root"></div>
</body>
</html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#0b0b0f" />
<title>Jukebox</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<script type="module" src="/src/main.tsx"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

1734
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
import type { Sound, SoundsResponse, VoiceChannelInfo } from './types';
import type { AnalyticsResponse, Sound, SoundsResponse, VoiceChannelInfo } from './types';
const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api';
@ -13,6 +13,12 @@ export async function fetchSounds(q?: string, folderKey?: string, categoryId?: s
return res.json();
}
export async function fetchAnalytics(): Promise<AnalyticsResponse> {
const res = await fetch(`${API_BASE}/analytics`);
if (!res.ok) throw new Error('Fehler beim Laden der Analytics');
return res.json();
}
// Kategorien
export async function fetchCategories() {
const res = await fetch(`${API_BASE}/categories`, { credentials: 'include' });
@ -200,7 +206,34 @@ export async function playUrl(url: string, guildId: string, channelId: string, v
}
}
// uploadFile removed (build reverted)
/** Lädt eine einzelne MP3/WAV-Datei hoch. Gibt den gespeicherten Dateinamen zurück. */
export function uploadFile(
file: File,
onProgress: (pct: number) => void,
): Promise<string> {
return new Promise((resolve, reject) => {
const form = new FormData();
form.append('files', file);
const xhr = new XMLHttpRequest();
xhr.open('POST', `${API_BASE}/upload`);
xhr.upload.onprogress = e => {
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
};
xhr.onload = () => {
if (xhr.status === 200) {
try {
const data = JSON.parse(xhr.responseText);
resolve(data.files?.[0]?.name ?? file.name);
} catch { resolve(file.name); }
} else {
try { reject(new Error(JSON.parse(xhr.responseText).error)); }
catch { reject(new Error(`HTTP ${xhr.status}`)); }
}
};
xhr.onerror = () => reject(new Error('Netzwerkfehler'));
xhr.send(form);
});
}

File diff suppressed because it is too large Load diff

View file

@ -25,6 +25,18 @@ export type VoiceChannelInfo = {
export type Category = { id: string; name: string; color?: string; sort?: number };
export type AnalyticsItem = {
name: string;
relativePath: string;
count: number;
};
export type AnalyticsResponse = {
totalSounds: number;
totalPlays: number;
mostPlayed: AnalyticsItem[];
};