Merge branch 'feature/nightly' into main
This commit is contained in:
commit
df58b5cc93
12 changed files with 7516 additions and 1553 deletions
8
.github/workflows/docker-build.yml
vendored
8
.github/workflows/docker-build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
2384
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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);
|
||||
resource = createAudioResource(ff.stdout as any, { inlineVolume: true, inputType: StreamType.Raw });
|
||||
// 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();
|
||||
|
||||
// Ordner-Statistik aus allItems ableiten
|
||||
const folderCounts = new Map<string, number>();
|
||||
for (const it of allItems) {
|
||||
if (it.folder) folderCounts.set(it.folder, (folderCounts.get(it.folder) ?? 0) + 1);
|
||||
}
|
||||
const folders: Array<{ key: string; name: string; count: number }> = [];
|
||||
|
||||
const subFolders = rootEntries.filter((d) => d.isDirectory());
|
||||
const folderItems: Array<{ fileName: string; name: string; folder: string; relativePath: string }> = [];
|
||||
for (const dirent of subFolders) {
|
||||
const folderName = dirent.name;
|
||||
const folderPath = path.join(SOUNDS_DIR, folderName);
|
||||
const entries = fs.readdirSync(folderPath, { withFileTypes: true });
|
||||
const audios = entries.filter((e) => {
|
||||
if (!e.isFile()) return false;
|
||||
const n = e.name.toLowerCase();
|
||||
return n.endsWith('.mp3') || n.endsWith('.wav');
|
||||
});
|
||||
for (const f of audios) {
|
||||
folderItems.push({
|
||||
fileName: f.name,
|
||||
name: path.parse(f.name).name,
|
||||
folder: folderName,
|
||||
relativePath: path.join(folderName, f.name)
|
||||
});
|
||||
for (const [key, count] of folderCounts) {
|
||||
folders.push({ key, name: key, count });
|
||||
}
|
||||
folders.push({ key: folderName, name: folderName, count: audios.length });
|
||||
}
|
||||
|
||||
const allItems = [...rootFiles, ...folderItems].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Zeitstempel für Neu-Logik
|
||||
type ItemWithTime = { fileName: string; name: string; folder: string; relativePath: string; mtimeMs: number };
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
||||
<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 class="p-4 sm:p-8">
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
1734
web/package-lock.json
generated
Normal file
1734
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
1784
web/src/App.tsx
1784
web/src/App.tsx
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
2575
web/src/styles.css
2575
web/src/styles.css
File diff suppressed because it is too large
Load diff
|
|
@ -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[];
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue