From 96687f3a5387327c4c45a6a7f87935ca445077eb Mon Sep 17 00:00:00 2001 From: Bot Date: Tue, 3 Mar 2026 16:08:56 +0100 Subject: [PATCH 01/35] Most Played: Top 10 statt Top 3 anzeigen Co-Authored-By: Claude Opus 4.6 --- web/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index dd144bb..fb48183 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -554,7 +554,7 @@ export default function App() { [adminFilteredSounds, adminSelection, soundKey]); const allVisibleSelected = adminFilteredSounds.length > 0 && selectedVisibleCount === adminFilteredSounds.length; - const analyticsTop = analytics.mostPlayed.slice(0, 3); + const analyticsTop = analytics.mostPlayed.slice(0, 10); const totalSoundsDisplay = analytics.totalSounds || total; const clockMain = clock.slice(0, 5); From 0b849b7775b479e602127655387c1ab94e87bfca Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 4 Mar 2026 22:34:10 +0100 Subject: [PATCH 02/35] Fix: Voice reconnect Endlosschleife verhindern - Re-Entranz-Guard (isReconnecting) verhindert parallele Handler - Max 3 Reconnect-Versuche bevor fresh join - Exponentieller Backoff (2s, 4s, 6s) zwischen Retries - Ready-State setzt Counter zurueck Co-Authored-By: Claude Opus 4.6 --- server/src/index.ts | 65 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 2016f8a..2366aa1 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -556,8 +556,25 @@ function attachVoiceLifecycle(state: GuildAudioState, guild: any) { // Mehrfach-Registrierung verhindern if ((connection as any).__lifecycleAttached) return; try { (connection as any).setMaxListeners?.(0); } catch {} + + // Retry-Tracking um Endlosschleife zu verhindern + let reconnectAttempts = 0; + const MAX_RECONNECT_ATTEMPTS = 3; + let isReconnecting = false; + connection.on('stateChange', async (oldS: any, newS: any) => { console.log(`${new Date().toISOString()} | VoiceConnection: ${oldS.status} -> ${newS.status}`); + + // Ready zurückgesetzt -> Retry-Counter reset + if (newS.status === VoiceConnectionStatus.Ready) { + reconnectAttempts = 0; + isReconnecting = false; + return; + } + + // Re-Entranz verhindern: wenn schon ein Reconnect läuft, nicht nochmal starten + if (isReconnecting) return; + try { if (newS.status === VoiceConnectionStatus.Disconnected) { // Versuche, die Verbindung kurzfristig neu auszuhandeln, sonst rejoin @@ -567,7 +584,25 @@ function attachVoiceLifecycle(state: GuildAudioState, guild: any) { entersState(connection, VoiceConnectionStatus.Connecting, 5_000) ]); } catch { - connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); + if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + reconnectAttempts++; + console.warn(`${new Date().toISOString()} | Disconnected rejoin attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`); + connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); + } else { + console.error(`${new Date().toISOString()} | Max reconnect attempts reached from Disconnected, destroying and rejoining fresh`); + reconnectAttempts = 0; + try { connection.destroy(); } catch {} + 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.Destroyed) { // Komplett neu beitreten @@ -582,14 +617,38 @@ function attachVoiceLifecycle(state: GuildAudioState, guild: any) { newConn.subscribe(state.player); attachVoiceLifecycle(state, guild); } else if (newS.status === VoiceConnectionStatus.Connecting || newS.status === VoiceConnectionStatus.Signalling) { + isReconnecting = true; try { await entersState(connection, VoiceConnectionStatus.Ready, 15_000); + // Ready wird oben im Handler behandelt } catch (e) { - console.warn(`${new Date().toISOString()} | Voice not ready from ${newS.status}, rejoin`, e); - connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); + reconnectAttempts++; + if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + const backoffMs = reconnectAttempts * 2_000; + console.warn(`${new Date().toISOString()} | Voice not ready from ${newS.status}, attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}, backoff ${backoffMs}ms`); + await new Promise(r => setTimeout(r, backoffMs)); + isReconnecting = false; + connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); + } else { + console.error(`${new Date().toISOString()} | Max reconnect attempts reached, destroying and rejoining fresh`); + reconnectAttempts = 0; + isReconnecting = false; + try { connection.destroy(); } catch {} + 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); + } } } } catch (e) { + isReconnecting = false; console.error(`${new Date().toISOString()} | Voice lifecycle handler error`, e); } }); From b0070bb972834d84de06848f0e3d2b14933ba20f Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 4 Mar 2026 23:52:14 +0100 Subject: [PATCH 03/35] ci: trigger nightly build From f26f90e0dc7ea570bab85dadb80370bbca03f231 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 5 Mar 2026 00:09:49 +0100 Subject: [PATCH 04/35] ci: use git.daddelolymp.de as registry --- .gitlab-ci.yml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7e25852..5a4aa47 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,9 +2,7 @@ stages: - build variables: - REGISTRY_HOST: "10.10.10.10:5050" - IMAGE_NAME: "$REGISTRY_HOST/$CI_PROJECT_PATH" - # Force clone via IP instead of hostname to bypass Unraid Docker DNS issues + IMAGE_NAME: "$CI_REGISTRY/$CI_PROJECT_PATH" CI_SERVER_URL: "http://10.10.10.10:9080" GITLAB_FEATURES: "" @@ -21,7 +19,7 @@ docker-build: cat > /kaniko/.docker/config.json < Date: Thu, 5 Mar 2026 00:31:55 +0100 Subject: [PATCH 05/35] Fix: ensureConnectionReady raeumt kaputte Connection auf statt sie zurueckzugeben --- server/src/index.ts | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 2366aa1..0b1aa02 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -518,26 +518,29 @@ async function handleCommand(message: Message, content: string) { } async function ensureConnectionReady(connection: VoiceConnection, channelId: string, guildId: string, guild: any): Promise { + // Versuch 1: Warten ob bestehende Connection ready wird 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); + console.warn(`${new Date().toISOString()} | VoiceConnection not ready, trying rejoin...`); } + // Versuch 2: Rejoin 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); + console.warn(`${new Date().toISOString()} | VoiceConnection still not ready after rejoin`); } - try { - connection.destroy(); - } catch {} + // Versuch 3: Komplett neu verbinden + try { connection.destroy(); } catch {} + guildAudioState.delete(guildId); + const newConn = joinVoiceChannel({ channelId, guildId, @@ -545,10 +548,16 @@ async function ensureConnectionReady(connection: VoiceConnection, channelId: str 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; + try { + await entersState(newConn, VoiceConnectionStatus.Ready, 15_000); + console.log(`${new Date().toISOString()} | VoiceConnection ready after fresh join`); + return newConn; + } catch (e3) { + console.error(`${new Date().toISOString()} | VoiceConnection failed after all attempts, cleaning up`); + try { newConn.destroy(); } catch {} + guildAudioState.delete(guildId); + throw new Error('Voice connection failed after 3 attempts'); + } } function attachVoiceLifecycle(state: GuildAudioState, guild: any) { From a34cc7e08de2e963187909838e4decc71ece38b1 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 5 Mar 2026 00:54:44 +0100 Subject: [PATCH 06/35] Add @snazzah/davey for Discord DAVE E2EE voice support --- server/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/server/package.json b/server/package.json index e02373e..1dba320 100644 --- a/server/package.json +++ b/server/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@discordjs/opus": "^0.9.0", + "@snazzah/davey": "^0.1.10", "@discordjs/voice": "^0.18.0", "cors": "^2.8.5", "discord.js": "^14.16.3", From 2e7f3bca7c2b5755d4286394c5fdad93129be2dd Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 5 Mar 2026 01:12:35 +0100 Subject: [PATCH 07/35] Upgrade @discordjs/voice to 0.19 for DAVE E2EE negotiation --- server/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/package.json b/server/package.json index 1dba320..e041b2a 100644 --- a/server/package.json +++ b/server/package.json @@ -12,7 +12,7 @@ "dependencies": { "@discordjs/opus": "^0.9.0", "@snazzah/davey": "^0.1.10", - "@discordjs/voice": "^0.18.0", + "@discordjs/voice": "^0.19.0", "cors": "^2.8.5", "discord.js": "^14.16.3", "express": "^4.19.2", From 84bf0bea51c84fa99c85f022242b2440cd6f65a4 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 5 Mar 2026 01:26:51 +0100 Subject: [PATCH 08/35] Fix: Most Played zeigt 10 statt 3, Playstate Label zu Last Played --- web/src/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index dd144bb..fb30ea6 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -554,7 +554,7 @@ export default function App() { [adminFilteredSounds, adminSelection, soundKey]); const allVisibleSelected = adminFilteredSounds.length > 0 && selectedVisibleCount === adminFilteredSounds.length; - const analyticsTop = analytics.mostPlayed.slice(0, 3); + const analyticsTop = analytics.mostPlayed.slice(0, 10); const totalSoundsDisplay = analytics.totalSounds || total; const clockMain = clock.slice(0, 5); @@ -624,7 +624,7 @@ export default function App() {
- {lastPlayed} + Last Played: {lastPlayed}
)} {selected && ( From 531ee85b2f55f3e56bfd56c41c0c5ad4541602ff Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 5 Mar 2026 10:42:49 +0100 Subject: [PATCH 09/35] Update: discord.js 14.25.1, typescript 5.9.3, @discordjs/opus 0.10.0 --- server/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/package.json b/server/package.json index e041b2a..e263811 100644 --- a/server/package.json +++ b/server/package.json @@ -10,11 +10,11 @@ "start": "node dist/index.js" }, "dependencies": { - "@discordjs/opus": "^0.9.0", + "@discordjs/opus": "^0.10.0", "@snazzah/davey": "^0.1.10", "@discordjs/voice": "^0.19.0", "cors": "^2.8.5", - "discord.js": "^14.16.3", + "discord.js": "^14.25.1", "express": "^4.19.2", "libsodium-wrappers": "^0.8.2", "multer": "^2.0.0", @@ -28,6 +28,6 @@ "@types/multer": "^1.4.12", "@types/node": "^20.12.12", "ts-node": "^10.9.2", - "typescript": "^5.5.4" + "typescript": "^5.9.3" } } From c7cdf98efc82d0371d91e4e1fa9c229addce9db6 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 5 Mar 2026 11:13:03 +0100 Subject: [PATCH 10/35] docs: README komplett ueberarbeitet --- README.md | 456 ++++++++++++++++++++++-------------------------------- 1 file changed, 189 insertions(+), 267 deletions(-) diff --git a/README.md b/README.md index 96e0e13..c5cba18 100644 --- a/README.md +++ b/README.md @@ -1,300 +1,222 @@ -# Jukebox 420 – Discord Soundboard (v1.1.2) +# Jukebox Vibe – Discord Soundboard -A modern, self‑hosted Discord soundboard with a slick web UI and a Discord bot that plays sounds into your voice channels. Easy to run via Docker, fun to use with friends. +Self-hosted Discord Soundboard mit Web-UI. Spielt Sounds in Discord Voice Channels, verwaltet per Browser. Dockerized, ein Container, fertig. -![Version](https://img.shields.io/badge/version-1.1.2-blue) -![Docker](https://img.shields.io/badge/docker-ready-green) -![Discord](https://img.shields.io/badge/discord-bot-purple) +## Tech Stack -## ✨ Features +| Komponente | Technologie | +|---|---| +| **Backend** | Node.js 20, Express, TypeScript, discord.js 14 | +| **Voice** | @discordjs/voice 0.19, @discordjs/opus, DAVE E2EE (@snazzah/davey) | +| **Frontend** | React 18, Vite, TypeScript, Custom CSS | +| **Audio** | ffmpeg (EBU R128 Loudnorm), yt-dlp | +| **Deployment** | Multi-Stage Dockerfile, GitLab CI/CD (Kaniko) | -- Web UI (Vite + React + TypeScript), 3 themes (Dark, Rainbow, 420) -- Discord bot (discord.js + @discordjs/voice) -- MP3 & WAV playback, ffmpeg normalization -- Favorites, search, folders view (auto counters) -- Live counters and a clean header/footer -- Admin area: bulk delete, inline rename, categories (CRUD) + bulk assign, remove custom badges -- Partymode: server‑side random playback every 30–90 seconds, globally synced via SSE; Panic stops for everyone -- Persistent state: volumes, plays, totalPlays, categories, badges in `/data/sounds/state.json` -- Entrance/Exit sounds: per‑user sounds played when joining/leaving voice; users set them via DM (`?entrance`, `?exit`); Exit plays only on disconnect (not on channel switch) +## Features -## 🚀 Quick start +### Soundboard +- **MP3/WAV Playback** in Discord Voice Channels +- **Loudness Normalization** (EBU R128) mit PCM-Cache fuer Instant-Playback +- **Per-Guild Volume** (0-100%), live adjustable, persistiert +- **Party Mode** – zufaellige Sounds alle 30-90 Sekunden +- **Stop/Panic** – sofortiger Playback-Stop fuer alle -### 1. Requirements -- Docker & Docker Compose -- Discord bot token with intents: `Guilds`, `GuildVoiceStates`, `DirectMessages` +### Discord Bot +- **Entrance Sounds** – persoenlicher Sound beim Channel-Join +- **Exit Sounds** – Sound beim Disconnect (nicht bei Channel-Wechsel) +- **DM Upload** – Sounds per Direktnachricht an den Bot hochladen +- **Voice Resilience** – 3-Tier Reconnect mit Exponential Backoff + +### Web-UI +- **Sound Grid** mit klickbaren Cards +- **Suche** mit optionalem Fuzzy-Matching +- **Ordner-Filter** mit farbigen Chips +- **Favoriten** (Cookie-basiert) +- **Kategorien** und **Badges** (Admin-verwaltet) +- **Channel-Selector** gruppiert nach Guild +- **Now Playing** Anzeige mit Wellenform-Animation +- **5 Themes** – Discord, Midnight, Forest, Sunset, Ocean +- **Card-Size Slider** (80-160px) +- **Drag & Drop Upload** (Admin) +- **MP3-URL Import** – direkter Download und Playback +- **Real-Time Sync** via Server-Sent Events (SSE) + +### Admin +- **Login** mit HMAC-SHA256 Token (HttpOnly Cookie, 7 Tage) +- **Bulk Delete / Rename** von Sounds +- **Upload** bis zu 20 Dateien (je max. 50MB) +- **Kategorien** erstellen, bearbeiten, loeschen, zuweisen +- **Custom Badges** zuweisen/entfernen + +## Quickstart + +### Voraussetzungen +- Docker +- Discord Bot Token mit Intents: `Guilds`, `GuildVoiceStates`, `GuildMembers`, `DirectMessages`, `MessageContent` + +### Setup -### 2. Setup ```bash -# Clone repository -git clone https://github.com/flex420/jukebox-vibe.git +git clone https://git.daddelolymp.de/root/jukebox-vibe.git cd jukebox-vibe - -# Create .env cp .env.example .env +# .env anpassen (DISCORD_TOKEN, ADMIN_PWD) +docker compose up -d ``` -### 3. Configuration -```env -# Edit the .env file -DISCORD_TOKEN=your_discord_bot_token_here -ADMIN_PWD=choose-a-strong-password -PORT=8080 -SOUNDS_DIR=/data/sounds +### Docker Run (ohne Compose) -# Optionally restrict allowed guilds -ALLOWED_GUILD_IDS=GUILD_ID_1,GUILD_ID_2 -``` - -### 4. Deployment -```bash -# Start container -docker compose up --build -d - -# Logs -docker compose logs -f - -# Status -docker compose ps -``` - -### 5. Access -- **Web-Interface**: `http://localhost:8199` -- **Health Check**: `http://localhost:8199/api/health` - -## 🎯 Usage - -### **Getting started** -1. Invite the Discord bot with voice permissions -2. Upload sounds via DM to the bot (MP3/WAV) -3. Open the web UI and choose a theme -4. Select a voice channel and play sounds - -### **Admin panel** -1. Log in with the admin password -2. Select sounds via checkboxes -3. Perform bulk delete or rename -4. Logout to finish - -### **URL downloads** -- Enter MP3/WAV links into the URL field -- Click Download -- The file will be added automatically to the soundboard - -## 🎨 Themes - -### **Dark Theme** -- Klassisches dunkles Design -- Blaue Akzente (#0a84ff) -- Glassmorphism-Effekte - -### **Rainbow Theme** -- Animierter Regenbogen-Hintergrund -- Bunte Borders und Effekte -- 15s Animation-Loop - -### **420 Theme** -- Cannabis-grüne Farbpalette -- Trippy animierte Gradienten -- 20s Animation-Loop -- Grüne Glow-Effekte - -## 📊 API endpoints - -### **Public Endpoints** -```http -GET /api/health # Health Check + Statistiken -GET /api/sounds # Sound-Liste mit Ordner-Struktur -GET /api/channels # Voice-Channel Liste -POST /api/play # Sound abspielen -POST /api/play-url # URL downloaden & abspielen -POST /api/stop # Aktuellen Sound stoppen -GET /api/volume # Volume abrufen -POST /api/volume # Volume setzen -``` - -### **Admin endpoints** -```http -POST /api/admin/login # Admin-Login -POST /api/admin/logout # Admin-Logout -GET /api/admin/status # Login-Status -POST /api/admin/sounds/delete # Sounds löschen -POST /api/admin/sounds/rename # Sound umbenennen -``` - -## 🔧 Discord bot commands - -### **DM commands** -- `?help` – show help -- `?list` – list all sounds -- `?entrance | remove` – set or remove your entrance sound -- `?exit | remove` – set or remove your exit sound - -### **Upload via DM** -- Send MP3/WAV files directly to the bot -- Files are stored under `/data/sounds` -- Immediately available in the frontend - -## 🐳 Docker deployment - -### **Docker Compose (Empfohlen)** -```yaml -# docker-compose.yml -services: - app: - build: . - container_name: discord-soundboard - ports: - - "8199:8080" - env_file: - - .env - volumes: - - ./data/sounds:/data/sounds - restart: unless-stopped -``` - -### **Docker Run** ```bash docker run -d \ - --name jukebox-420 \ + --name jukebox \ -p 8199:8080 \ - --env-file .env \ - -v $(pwd)/data/sounds:/data/sounds \ - flex420/jukebox-vibe:latest + -e DISCORD_TOKEN=dein_token \ + -e ADMIN_PWD=dein_passwort \ + -v $(pwd)/sounds:/data/sounds \ + --restart unless-stopped \ + git.daddelolymp.de/root/jukebox-vibe:latest ``` -### **Docker Hub** -```bash -# Image pullen -docker pull flex420/jukebox-vibe:latest +## Konfiguration -# Container starten -docker run -d --name jukebox-420 -p 8199:8080 --env-file .env -v $(pwd)/data/sounds:/data/sounds flex420/jukebox-vibe:latest +| Variable | Default | Beschreibung | +|---|---|---| +| `DISCORD_TOKEN` | *erforderlich* | Discord Bot Token | +| `ADMIN_PWD` | `''` | Admin-Passwort fuer Web-UI | +| `PORT` | `8080` | Server Port | +| `SOUNDS_DIR` | `/data/sounds` | Sound-Verzeichnis (Docker Volume) | +| `ALLOWED_GUILD_IDS` | `''` | Komma-getrennte Guild-IDs (leer = alle) | +| `NORMALIZE_ENABLE` | `true` | Loudness Normalization an/aus | +| `NORMALIZE_I` | `-16` | Ziel-Lautstaerke in LUFS | +| `NORMALIZE_LRA` | `11` | Loudness Range | +| `NORMALIZE_TP` | `-1.5` | True Peak in dBTP | +| `NORM_CONCURRENCY` | `2` | Parallele ffmpeg-Prozesse | + +## Bot Commands + +Alle Commands per DM an den Bot oder im Server-Chat: + +| Command | Beschreibung | +|---|---| +| `?help` | Hilfe anzeigen | +| `?list` | Alle Sounds auflisten | +| `?entrance ` | Entrance-Sound setzen | +| `?entrance remove` | Entrance-Sound entfernen | +| `?exit ` | Exit-Sound setzen | +| `?exit remove` | Exit-Sound entfernen | + +**Upload via DM:** MP3/WAV als Anhang an den Bot senden – wird automatisch gespeichert und normalisiert. + +## API + +### Public + +``` +GET /api/health Healthcheck + Stats +GET /api/sounds Sound-Liste (query: q, folder, categoryId, fuzzy) +GET /api/analytics Top 10 Most Played, Gesamt-Plays +GET /api/channels Verfuegbare Voice Channels +GET /api/selected-channels Gespeicherte Channel-Auswahl +POST /api/selected-channel Channel-Auswahl setzen +POST /api/play Sound abspielen (body: soundName, guildId, channelId) +POST /api/play-url MP3-URL downloaden und abspielen +POST /api/stop Playback stoppen +GET /api/volume Volume abfragen +POST /api/volume Volume setzen (0-1) +POST /api/party/start Party Mode starten +POST /api/party/stop Party Mode stoppen +GET /api/events SSE Stream (Real-Time Updates) ``` -## 🔒 SSL/HTTPS Hinweis (wichtig für Discord) +### Admin (Cookie-Auth) -- Das Web-Frontend MUSS hinter HTTPS (SSL) ausgeliefert werden. Empfohlen ist ein Domain‑Mapping (Reverse Proxy) mit gültigem Zertifikat (z. B. Traefik, Nginx, Caddy, Cloudflare). -- Hintergrund: Ohne TLS kann es zu Verschlüsselungs-/Encrypt‑Fehlern kommen, und Audio wird in Discord nicht korrekt wiedergegeben. -- Praxis: Richte eine Domain wie `https://soundboard.deinedomain.tld` auf das Frontend ein und aktiviere SSL (Let’s Encrypt). Danach sollten Uploads/Playback stabil funktionieren. +``` +POST /api/admin/login Login (body: password) +POST /api/admin/logout Logout +GET /api/admin/status Auth-Status pruefen +POST /api/upload Dateien hochladen (multipart, max 20x50MB) +POST /api/admin/sounds/delete Sounds loeschen (body: paths[]) +POST /api/admin/sounds/rename Sound umbenennen +GET /api/categories Kategorien auflisten +POST /api/categories Kategorie erstellen +PATCH /api/categories/:id Kategorie bearbeiten +DELETE /api/categories/:id Kategorie loeschen +POST /api/categories/assign Kategorien zuweisen +POST /api/badges/assign Badges zuweisen +POST /api/badges/clear Badges entfernen +``` -## 📁 Project structure +## Sound-Dateien + +``` +/data/sounds/ + state.json # Persistierter State (Volumes, Plays, Kategorien, etc.) + .norm-cache/ # Normalisierte PCM-Caches + airhorn.mp3 # Sounds im Root + memes/ # Ordner-Struktur (1 Ebene) + bruh.mp3 + oof.wav +``` + +- Formate: `.mp3`, `.wav` +- Ordner werden als Filter-Chips im Frontend angezeigt +- `.norm-cache/` wird automatisch verwaltet (Cache-Invalidierung bei Datei-Aenderung) + +## Loudness Normalization + +Alle Sounds werden per ffmpeg `loudnorm` (EBU R128) normalisiert: + +1. **Startup:** Hintergrund-Sync normalisiert alle uncached Sounds +2. **Cache Hit:** Gecachte PCM-Datei wird direkt gestreamt (kein ffmpeg, instant) +3. **Cache Miss:** ffmpeg streamt live zum Player UND schreibt gleichzeitig in Cache +4. **Upload:** Neue Dateien werden sofort im Hintergrund normalisiert + +## Voice Connection + +3-stufiges Recovery bei Verbindungsproblemen: + +1. **Wait for Ready** (15s Timeout) +2. **Rejoin** im selben Channel (15s Timeout) +3. **Destroy + Fresh Join** (15s Timeout) + +Lifecycle-Handler mit: +- Max 3 Reconnect-Versuche mit Exponential Backoff +- Anti-Reentrancy Guard gegen Endlosschleifen +- Automatisches State-Cleanup bei totalem Verbindungsverlust + +## Projektstruktur ``` jukebox-vibe/ -├── server/ # Backend (Node.js/Express) -│ ├── src/ -│ │ ├── index.ts # Main Server + Discord Bot -│ │ └── types/ # TypeScript Definitions -│ ├── package.json -│ └── tsconfig.json -├── web/ # Frontend (React/Vite) -│ ├── src/ -│ │ ├── App.tsx # Main React Component -│ │ ├── api.ts # API Client -│ │ ├── styles.css # Theme Styles -│ │ └── types.ts # TypeScript Types -│ ├── package.json -│ └── index.html -├── docker-compose.yml # Docker Compose Config -├── Dockerfile # Multi-Stage Build -├── .env.example # Environment Template -└── README.md # Diese Datei + server/ + src/index.ts # Server + Discord Bot (Hauptdatei) + package.json + tsconfig.json + web/ + src/ + App.tsx # React SPA + styles.css # Themes + Layout + types.ts # TypeScript Types + package.json + vite.config.ts + Dockerfile # Multi-Stage Build (web + server + runtime) + docker-compose.yml + .gitlab-ci.yml # CI/CD Pipeline (Kaniko) + .env.example ``` -## 🔧 Development - -### **Lokale Entwicklung** -```bash -# Backend -cd server -npm install -npm run dev - -# Frontend (neues Terminal) -cd web -npm install -npm run dev -``` - -### **Build** -```bash -# Production Build -docker build -t jukebox-vibe . - -# Development Build -docker build --target development -t jukebox-vibe:dev . -``` - -## 📈 Stats - -### **Persistent data** -- Sounds: `/data/sounds/` (volume mount) -- State: `/data/sounds/state.json` (volume, channel, plays) -- Favorites: browser cookies -- Theme: browser localStorage - -### **Monitoring** -- Health check: `/api/health` -- Docker logs: `docker compose logs -f` -- Container status: `docker compose ps` - -## 🛠️ Troubleshooting - -### **Häufige Probleme** - -**Bot does not join the voice channel:** -- Check bot permissions (Connect, Speak, Request to Speak for Stage) -- Verify gateway intents in the Discord Developer Portal (GuildVoiceStates, DirectMessages, MessageContent) -- Check network/firewall - -**Sounds do not show up:** -- Verify volume mount `/data/sounds` -- Check file permissions -- Confirm uploads via DM - -**Admin login fails:** -- Check browser cookies -- Confirm admin password -- Inspect server logs - -### **Logs anzeigen** -```bash -# Docker Compose Logs -docker compose logs -f - -# Container-spezifische Logs -docker compose logs -f app - -# Letzte 100 Zeilen -docker compose logs --tail=100 app -``` - -## 🤝 Contributing - -1. **Fork** das Repository -2. **Feature Branch** erstellen (`git checkout -b feature/AmazingFeature`) -3. **Commit** Änderungen (`git commit -m 'Add AmazingFeature'`) -4. **Push** zum Branch (`git push origin feature/AmazingFeature`) -5. **Pull Request** erstellen - -## 📄 Lizenz - -Dieses Projekt ist unter der MIT Lizenz lizenziert - siehe [LICENSE](LICENSE) Datei für Details. - -## 🙏 Credits - -- **Discord.js** für Bot-Funktionalität -- **React** für das Frontend -- **Vite** für Build-Tooling -- **Docker** für Containerisierung -- **Tailwind CSS** für Styling - ---- - -**🎵 Jukebox 420** - Dein ultimatives Discord Soundboard! 🚀 +## CI/CD +GitLab CI baut automatisch bei jedem Push: +| Branch | Image Tag | +|---|---| +| `main` | `:main`, `:latest`, `:sha` | +| `nightly` | `:nightly`, `:sha` | +| andere | `:`, `:sha` | +Registry: `git.daddelolymp.de/root/jukebox-vibe` +## Lizenz +MIT From f043ff97bbd490be2d16b25048dad7d2bae1ddfa Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 5 Mar 2026 11:21:01 +0100 Subject: [PATCH 11/35] Update: express v4->v5, @types/express v4->v5 --- server/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/package.json b/server/package.json index e263811..c75c506 100644 --- a/server/package.json +++ b/server/package.json @@ -15,7 +15,7 @@ "@discordjs/voice": "^0.19.0", "cors": "^2.8.5", "discord.js": "^14.25.1", - "express": "^4.19.2", + "express": "^5.2.1", "libsodium-wrappers": "^0.8.2", "multer": "^2.0.0", "sodium-native": "^4.0.8", @@ -24,7 +24,7 @@ }, "devDependencies": { "@types/cors": "^2.8.17", - "@types/express": "^4.17.21", + "@types/express": "^5.0.6", "@types/multer": "^1.4.12", "@types/node": "^20.12.12", "ts-node": "^10.9.2", From 5c45c098e2674729738c87984c55c9122a94cff9 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 5 Mar 2026 11:30:21 +0100 Subject: [PATCH 12/35] Fix: Express v5 wildcard route, add global error handlers --- server/src/index.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/server/src/index.ts b/server/src/index.ts index 0b1aa02..121c097 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1530,11 +1530,19 @@ app.post('/api/play-url', async (req: Request, res: Response) => { const webDistPath = path.resolve(__dirname, '../../web/dist'); if (fs.existsSync(webDistPath)) { app.use(express.static(webDistPath)); - app.get('*', (_req, res) => { + app.get('/{*splat}', (_req, res) => { res.sendFile(path.join(webDistPath, 'index.html')); }); } +process.on('uncaughtException', (err) => { + console.error(`FATAL uncaughtException:`, err); + process.exit(1); +}); +process.on('unhandledRejection', (reason) => { + console.error(`FATAL unhandledRejection:`, reason); +}); + app.listen(PORT, () => { console.log(`Server läuft auf http://0.0.0.0:${PORT}`); From 5d01d3b4db60780547c96b191ede98853aa1d9ed Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 5 Mar 2026 14:09:40 +0100 Subject: [PATCH 13/35] Update: sodium-native v4->v5, @types/multer v1->v2 --- server/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/package.json b/server/package.json index c75c506..f4996ef 100644 --- a/server/package.json +++ b/server/package.json @@ -18,14 +18,14 @@ "express": "^5.2.1", "libsodium-wrappers": "^0.8.2", "multer": "^2.0.0", - "sodium-native": "^4.0.8", + "sodium-native": "^5.0.10", "tweetnacl": "^1.0.3", "ws": "^8.18.0" }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^5.0.6", - "@types/multer": "^1.4.12", + "@types/multer": "^2.0.0", "@types/node": "^20.12.12", "ts-node": "^10.9.2", "typescript": "^5.9.3" From cde29698ca7277e6363666aa5eae32360c48d99e Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 5 Mar 2026 14:52:44 +0100 Subject: [PATCH 14/35] upgrade to Node 24 LTS + @types/node v24 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dockerfile: node:20-slim → node:24-slim (all 3 stages) - @types/node: ^20.12.12 → ^24.0.0 - Fixes EBADENGINE warning (@discordjs/voice requires Node >=22) - Brings npm 11 automatically - Node 24 LTS supported until April 2028 --- Dockerfile | 6 +++--- server/package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index b99af26..21cd07d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # Multi-stage build: Frontend (Vite) + Backend (Express + discord.js) # --- Build frontend (npm) --- -FROM node:20-slim AS web-build +FROM node:24-slim AS web-build WORKDIR /app/web COPY web/package*.json ./ RUN npm install --no-audit --no-fund @@ -14,7 +14,7 @@ ENV VITE_APP_VERSION=$VITE_APP_VERSION RUN npm run build # --- Build server (npm) --- -FROM node:20-slim AS server-build +FROM node:24-slim AS server-build WORKDIR /app/server RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* COPY server/package*.json ./ @@ -25,7 +25,7 @@ RUN npm run build RUN rm -rf node_modules && npm install --omit=dev --no-audit --no-fund # --- Runtime image --- -FROM node:20-slim AS runtime +FROM node:24-slim AS runtime WORKDIR /app ENV NODE_ENV=production ENV PORT=8080 diff --git a/server/package.json b/server/package.json index f4996ef..f6e00d8 100644 --- a/server/package.json +++ b/server/package.json @@ -26,7 +26,7 @@ "@types/cors": "^2.8.17", "@types/express": "^5.0.6", "@types/multer": "^2.0.0", - "@types/node": "^20.12.12", + "@types/node": "^24.0.0", "ts-node": "^10.9.2", "typescript": "^5.9.3" } From 901f0bf1ddc3725d24c7aea94e500422ff045832 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 5 Mar 2026 15:10:18 +0100 Subject: [PATCH 15/35] perf: in-memory PCM cache + skip InlineVolume at vol 1.0 - PCM-Dateien werden beim ersten Abspielen in den RAM geladen (Map) - Nachfolgende Plays lesen aus RAM statt Disk -> eliminiert I/O-Latenz - InlineVolume Transform wird bei Volume 1.0 uebersprungen (unnoetige Sample-Verarbeitung) - Fallback createReadStream mit 256KB highWaterMark fuer schnelleres Buffering - Memory-Cache-Limit konfigurierbar via PCM_CACHE_MAX_MB env (default 512MB) - Cache-Invalidierung bei Quelldatei-Aenderungen --- server/src/index.ts | 3157 ++++++++++++++++++++++--------------------- 1 file changed, 1602 insertions(+), 1555 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 121c097..b291e02 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,1555 +1,1602 @@ -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 cors from 'cors'; -import crypto from 'node:crypto'; -import { Client, GatewayIntentBits, Partials, ChannelType, Events, type Message, VoiceState } from 'discord.js'; -import { - joinVoiceChannel, - createAudioPlayer, - createAudioResource, - AudioPlayerStatus, - NoSubscriberBehavior, - getVoiceConnection, - type VoiceConnection, - type AudioResource, - StreamType, - generateDependencyReport, - entersState, - VoiceConnectionStatus -} from '@discordjs/voice'; -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); - -// --- Config --- -const PORT = Number(process.env.PORT ?? 8080); -const SOUNDS_DIR = process.env.SOUNDS_DIR ?? '/data/sounds'; -const DISCORD_TOKEN = process.env.DISCORD_TOKEN ?? ''; -const ADMIN_PWD = process.env.ADMIN_PWD ?? ''; -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 }); - -// Persistenter Zustand: Lautstärke/Plays + Kategorien -type Category = { id: string; name: string; color?: string; sort?: number }; - type PersistedState = { - volumes: Record; - plays: Record; - totalPlays: number; - categories?: Category[]; - fileCategories?: Record; // relPath or fileName -> categoryIds[] - fileBadges?: Record; // relPath or fileName -> custom badges (emoji/text) - selectedChannels?: Record; // guildId -> channelId (serverweite Auswahl) - entranceSounds?: Record; // userId -> relativePath or fileName - exitSounds?: Record; // userId -> relativePath or fileName -}; -// 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'); - -function readPersistedState(): PersistedState { - try { - // 1) Bevorzugt neuen Speicherort lesen - if (fs.existsSync(STATE_FILE_NEW)) { - const raw = fs.readFileSync(STATE_FILE_NEW, 'utf8'); - const parsed = JSON.parse(raw); - return { - volumes: parsed.volumes ?? {}, - plays: parsed.plays ?? {}, - totalPlays: parsed.totalPlays ?? 0, - categories: Array.isArray(parsed.categories) ? parsed.categories : [], - fileCategories: parsed.fileCategories ?? {}, - fileBadges: parsed.fileBadges ?? {}, - selectedChannels: parsed.selectedChannels ?? {}, - entranceSounds: parsed.entranceSounds ?? {}, - exitSounds: parsed.exitSounds ?? {} - } as PersistedState; - } - // 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); - const migrated: PersistedState = { - volumes: parsed.volumes ?? {}, - plays: parsed.plays ?? {}, - totalPlays: parsed.totalPlays ?? 0, - categories: Array.isArray(parsed.categories) ? parsed.categories : [], - fileCategories: parsed.fileCategories ?? {}, - fileBadges: parsed.fileBadges ?? {}, - selectedChannels: parsed.selectedChannels ?? {}, - entranceSounds: parsed.entranceSounds ?? {}, - exitSounds: parsed.exitSounds ?? {} - }; - try { - fs.mkdirSync(path.dirname(STATE_FILE_NEW), { recursive: true }); - fs.writeFileSync(STATE_FILE_NEW, JSON.stringify(migrated, null, 2), 'utf8'); - } catch {} - return migrated; - } - } catch {} - return { volumes: {}, plays: {}, totalPlays: 0 }; -} - -function writePersistedState(state: PersistedState): void { - try { - fs.mkdirSync(path.dirname(STATE_FILE_NEW), { recursive: true }); - fs.writeFileSync(STATE_FILE_NEW, JSON.stringify(state, null, 2), 'utf8'); - } catch (e) { - console.warn('Persisted state konnte nicht geschrieben werden:', e); - } -} - -const persistedState: PersistedState = readPersistedState(); - -// Debounced write: sammelt Schreibaufrufe und schreibt max. alle 2 Sekunden -let _writeTimer: ReturnType | 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; - writePersistedStateDebounced(); // Debounced: Play-Counter sind nicht kritisch - } 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'); - -// 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 { - 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 { - if (!NORMALIZE_ENABLE) return; - const t0 = Date.now(); - const allSounds = listAllSounds(); - - // Set aller erwarteten Cache-Keys - const expectedKeys = new Set(); - 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 { - 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 -void nacl.randomBytes(1); -console.log(generateDependencyReport()); - -// --- Discord Client --- -const client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildVoiceStates, - GatewayIntentBits.DirectMessages, - GatewayIntentBits.MessageContent, - ], - partials: [Partials.Channel] -}); - -type GuildAudioState = { - connection: VoiceConnection; - player: ReturnType; - guildId: string; - channelId: string; - currentResource?: AudioResource; - currentVolume: number; // 0..1 -}; -const guildAudioState = new Map(); -// Partymode: serverseitige Steuerung (global pro Guild) -const partyTimers = new Map(); -const partyActive = new Set(); -// Now-Playing: aktuell gespielter Sound pro Guild -const nowPlaying = new Map(); -// SSE-Klienten für Broadcasts (z.B. Partymode Status) -const sseClients = new Set(); -function sseBroadcast(payload: any) { - const data = `data: ${JSON.stringify(payload)}\n\n`; - for (const res of sseClients) { - try { res.write(data); } catch {} - } -} - -// 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 }); -} - -async function playFilePath(guildId: string, channelId: string, filePath: string, volume?: number, relativeKey?: string): Promise { - 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); - } - // 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 - }); - // Reuse bestehenden Player falls vorhanden - 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 {} - - // 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); - } - const useVolume = typeof volume === 'number' && Number.isFinite(volume) - ? Math.max(0, Math.min(1, volume)) - : (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); - // 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 }); - } - if (resource.volume) resource.volume.setVolume(useVolume); - state.player.stop(); - 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); -} - -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' + - '?list - listet alle Audio-Dateien (mp3/wav)\n' + - '?entrance | remove - setze oder entferne deinen Entrance-Sound\n' + - '?exit | remove - setze oder entferne deinen Exit-Sound\n' - ); - return; - } - if (cmd === '?list') { - const files = fs - .readdirSync(SOUNDS_DIR) - .filter(f => { const l = f.toLowerCase(); return l.endsWith('.mp3') || l.endsWith('.wav'); }); - await reply(files.length ? files.join('\n') : 'Keine Dateien gefunden.'); - return; - } - if (cmd === '?entrance') { - 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 | 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; - } - 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; } - 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 {} - await reply(`Entrance-Sound gesetzt: ${resolve}`); return; - } - if (cmd === '?exit') { - 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 | 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; - } - 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; } - 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 {} - await reply(`Exit-Sound gesetzt: ${resolve}`); return; - } - await reply('Unbekannter Command. Nutze ?help.'); -} - -async function ensureConnectionReady(connection: VoiceConnection, channelId: string, guildId: string, guild: any): Promise { - // Versuch 1: Warten ob bestehende Connection ready wird - 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...`); - } - - // Versuch 2: Rejoin - 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.warn(`${new Date().toISOString()} | VoiceConnection still not ready after rejoin`); - } - - // Versuch 3: Komplett neu verbinden - try { connection.destroy(); } catch {} - guildAudioState.delete(guildId); - - const newConn = joinVoiceChannel({ - channelId, - guildId, - adapterCreator: guild.voiceAdapterCreator as any, - selfMute: false, - selfDeaf: false - }); - try { - await entersState(newConn, VoiceConnectionStatus.Ready, 15_000); - console.log(`${new Date().toISOString()} | VoiceConnection ready after fresh join`); - return newConn; - } catch (e3) { - console.error(`${new Date().toISOString()} | VoiceConnection failed after all attempts, cleaning up`); - try { newConn.destroy(); } catch {} - guildAudioState.delete(guildId); - throw new Error('Voice connection failed after 3 attempts'); - } -} - -function attachVoiceLifecycle(state: GuildAudioState, guild: any) { - const { connection } = state; - // Mehrfach-Registrierung verhindern - if ((connection as any).__lifecycleAttached) return; - try { (connection as any).setMaxListeners?.(0); } catch {} - - // Retry-Tracking um Endlosschleife zu verhindern - let reconnectAttempts = 0; - const MAX_RECONNECT_ATTEMPTS = 3; - let isReconnecting = false; - - connection.on('stateChange', async (oldS: any, newS: any) => { - console.log(`${new Date().toISOString()} | VoiceConnection: ${oldS.status} -> ${newS.status}`); - - // Ready zurückgesetzt -> Retry-Counter reset - if (newS.status === VoiceConnectionStatus.Ready) { - reconnectAttempts = 0; - isReconnecting = false; - return; - } - - // Re-Entranz verhindern: wenn schon ein Reconnect läuft, nicht nochmal starten - if (isReconnecting) return; - - 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 { - if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { - reconnectAttempts++; - console.warn(`${new Date().toISOString()} | Disconnected rejoin attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`); - connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); - } else { - console.error(`${new Date().toISOString()} | Max reconnect attempts reached from Disconnected, destroying and rejoining fresh`); - reconnectAttempts = 0; - try { connection.destroy(); } catch {} - 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.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) { - isReconnecting = true; - try { - await entersState(connection, VoiceConnectionStatus.Ready, 15_000); - // Ready wird oben im Handler behandelt - } catch (e) { - reconnectAttempts++; - if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { - const backoffMs = reconnectAttempts * 2_000; - console.warn(`${new Date().toISOString()} | Voice not ready from ${newS.status}, attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}, backoff ${backoffMs}ms`); - await new Promise(r => setTimeout(r, backoffMs)); - isReconnecting = false; - connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); - } else { - console.error(`${new Date().toISOString()} | Max reconnect attempts reached, destroying and rejoining fresh`); - reconnectAttempts = 0; - isReconnecting = false; - try { connection.destroy(); } catch {} - 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); - } - } - } - } catch (e) { - isReconnecting = false; - console.error(`${new Date().toISOString()} | Voice lifecycle handler error`, e); - } - }); - (connection as any).__lifecycleAttached = true; -} - -client.once(Events.ClientReady, () => { - console.log(`Bot eingeloggt als ${client.user?.tag}`); -}); - -// Voice State Updates: Entrance/Exit -client.on(Events.VoiceStateUpdate, async (oldState: VoiceState, newState: VoiceState) => { - try { - const userId = (newState.id || oldState.id) as string; - if (!userId) return; - // Eigene Events ignorieren - if (userId === client.user?.id) return; - const guildId = (newState.guild?.id || oldState.guild?.id) as string; - if (!guildId) return; - - const before = oldState.channelId; - const after = newState.channelId; - console.log(`${new Date().toISOString()} | VoiceStateUpdate user=${userId} before=${before ?? '-'} after=${after ?? '-'}`); - - // 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}`); - 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)) { - try { - // Dem Channel beitreten und Sound spielen - await playFilePath(guildId, after, abs, undefined, rel); - console.log(`${new Date().toISOString()} | Entrance played for ${userId}: ${rel}`); - } catch (e) { console.warn('Entrance play error', e); } - } - } - } - // 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}`); - 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)) { - try { - await playFilePath(guildId, before, abs, undefined, rel); - console.log(`${new Date().toISOString()} | Exit played for ${userId}: ${rel}`); - } catch (e) { console.warn('Exit play error', e); } - } - } - } 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}`); - } - } catch (e) { - console.warn('VoiceStateUpdate entrance/exit handling error', e); - } -}); - -client.on(Events.MessageCreate, async (message: Message) => { - try { - if (message.author?.bot) return; - // Commands überall annehmen (inkl. DMs) - const content = (message.content || '').trim(); - if (content.startsWith('?')) { - await handleCommand(message, content); - return; - } - // Dateiuploads nur per DM - if (!message.channel?.isDMBased?.()) return; - if (message.attachments.size === 0) return; - - for (const [, attachment] of message.attachments) { - const name = attachment.name ?? 'upload'; - const lower = name.toLowerCase(); - if (!(lower.endsWith('.mp3') || lower.endsWith('.wav'))) continue; - - 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; - const ext = path.parse(safeName).ext || (lower.endsWith('.wav') ? '.wav' : '.mp3'); - 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)); - // 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) { - 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) => { - 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(); - 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 { - 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')) }); -}); - -app.get('/api/sounds', (req: Request, res: Response) => { - const q = String(req.query.q ?? '').toLowerCase(); - const folderFilter = typeof req.query.folder === 'string' ? (req.query.folder as string) : '__all__'; - const categoryFilter = typeof (req.query as any).categoryId === 'string' ? String((req.query as any).categoryId) : undefined; - const fuzzyParam = String((req.query as any).fuzzy ?? '0'); - const useFuzzy = fuzzyParam === '1' || fuzzyParam === 'true'; - - const allItems = listAllSounds(); - - // Ordner-Statistik aus allItems ableiten - const folderCounts = new Map(); - 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 }); - } - - // 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)); - let itemsByFolder = allItems; - if (folderFilter !== '__all__') { - 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)); - } - } - // 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) { - 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)); - } - } - - const total = allItems.length; - const recentCount = Math.min(10, total); - // 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 }>; - - const foldersOut = [ - { key: '__all__', name: 'Alle', count: total }, - { key: '__recent__', name: 'Neu', count: recentCount }, - ...(top3.length ? [{ key: '__top3__', name: 'Most Played (3)', count: top3.length }] : []), - ...folders - ]; - // isRecent-Flag für UI (Top 5 der neuesten) - // Kategorie-Filter (virtuell) anwenden, wenn gesetzt - let result = filteredItems; - if (categoryFilter) { - const fc = persistedState.fileCategories ?? {}; - result = result.filter((it) => { - const key = it.relativePath ?? it.fileName; - const cats = fc[key] ?? []; - return cats.includes(categoryFilter); - }); - } - if (folderFilter === '__top3__') { - const keys = new Set(top3.map(t => t.key.split(':')[1])); - result = allItems.filter(i => keys.has(i.relativePath ?? i.fileName)); - } - - // 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; - }); - - res.json({ items: withRecentFlag, total, folders: foldersOut, categories: persistedState.categories ?? [], fileCategories: persistedState.fileCategories ?? {} }); -}); - -// --- 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) { - 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' }); - } - } 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' }); - 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 = 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 ?? [] }); -}); - -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 }); -}); - -// 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 }); -}); - -// 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 }); -}); - -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); - const result: Array<{ guildId: string; guildName: string; channelId: string; channelName: string; selected?: boolean }> = []; - 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) { - const sel = getSelectedChannelForGuild(guild.id); - result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name, selected: sel === ch.id }); - } - } - } - result.sort((a, b) => a.guildName.localeCompare(b.guildName) || a.channelName.localeCompare(b.channelName)); - res.json(result); -}); - -// 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' }); - } -}); - -app.post('/api/play', async (req: Request, res: Response) => { - try { - const { soundName, guildId, channelId, volume, folder, relativePath } = req.body as { - soundName?: string; - guildId?: string; - channelId?: string; - volume?: number; // 0..1 - folder?: string; // optional subfolder key - relativePath?: string; // optional direct relative path - }; - if (!soundName || !guildId || !channelId) return res.status(400).json({ error: 'soundName, guildId, channelId erforderlich' }); - - let filePath: string; - if (relativePath) filePath = path.join(SOUNDS_DIR, relativePath); - 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; - } - if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Sound nicht gefunden' }); - - // 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); - return res.status(500).json({ error: err?.message ?? 'Unbekannter Fehler' }); - } -}); - -// 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) { - // Kein aktiver Player: nur persistieren für nächste Wiedergabe - persistedState.volumes[guildId] = safeVolume; - writePersistedState(persistedState); - // Broadcast neue Lautstärke an alle Clients - sseBroadcast({ type: 'volume', guildId, volume: safeVolume }); - return res.json({ ok: true, volume: safeVolume, persistedOnly: true }); - } - state.currentVolume = safeVolume; - if (state.currentResource?.volume) { - state.currentResource.volume.setVolume(safeVolume); - console.log(`${new Date().toISOString()} | live setVolume(${safeVolume}) guild=${guildId}`); - } - persistedState.volumes[guildId] = safeVolume; - writePersistedState(persistedState); - // Broadcast neue Lautstärke an alle Clients - sseBroadcast({ type: 'volume', guildId, volume: safeVolume }); - 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' }); - } -}); - -// 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 }); -}); - -// 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); - // 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); - if (t) clearTimeout(t); - partyTimers.delete(guildId); - partyActive.delete(guildId); - sseBroadcast({ type: 'party', guildId, active: false }); - } 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(); - // Broadcast Status - sseBroadcast({ type: 'party', guildId, active: true, channelId }); -} - -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); - sseBroadcast({ type: 'party', guildId: id, active: false }); - return res.json({ ok: true }); - } catch (e: any) { - console.error('party/stop error', e); - return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' }); - } -}); - -// 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 - try { - 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 - const ping = setInterval(() => { try { res.write(':\n\n'); } catch {} }, 15_000); - - sseClients.add(res); - req.on('close', () => { - sseClients.delete(res); - clearInterval(ping); - try { res.end(); } catch {} - }); -}); - -// --- 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)) { - app.use(express.static(webDistPath)); - app.get('/{*splat}', (_req, res) => { - res.sendFile(path.join(webDistPath, 'index.html')); - }); -} - -process.on('uncaughtException', (err) => { - console.error(`FATAL uncaughtException:`, err); - process.exit(1); -}); -process.on('unhandledRejection', (reason) => { - console.error(`FATAL unhandledRejection:`, reason); -}); - -app.listen(PORT, () => { - console.log(`Server läuft auf http://0.0.0.0:${PORT}`); - - // Vollständige Cache-Synchronisation beim Start (Hintergrund) - syncNormCache(); -}); - - - - +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 cors from 'cors'; +import crypto from 'node:crypto'; +import { Client, GatewayIntentBits, Partials, ChannelType, Events, type Message, VoiceState } from 'discord.js'; +import { + joinVoiceChannel, + createAudioPlayer, + createAudioResource, + AudioPlayerStatus, + NoSubscriberBehavior, + getVoiceConnection, + type VoiceConnection, + type AudioResource, + StreamType, + generateDependencyReport, + entersState, + VoiceConnectionStatus +} from '@discordjs/voice'; +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, Readable } from 'node:stream'; + +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 ?? ''; +const ADMIN_PWD = process.env.ADMIN_PWD ?? ''; +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 }); + +// Persistenter Zustand: Lautstärke/Plays + Kategorien +type Category = { id: string; name: string; color?: string; sort?: number }; + type PersistedState = { + volumes: Record; + plays: Record; + totalPlays: number; + categories?: Category[]; + fileCategories?: Record; // relPath or fileName -> categoryIds[] + fileBadges?: Record; // relPath or fileName -> custom badges (emoji/text) + selectedChannels?: Record; // guildId -> channelId (serverweite Auswahl) + entranceSounds?: Record; // userId -> relativePath or fileName + exitSounds?: Record; // userId -> relativePath or fileName +}; +// 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'); + +function readPersistedState(): PersistedState { + try { + // 1) Bevorzugt neuen Speicherort lesen + if (fs.existsSync(STATE_FILE_NEW)) { + const raw = fs.readFileSync(STATE_FILE_NEW, 'utf8'); + const parsed = JSON.parse(raw); + return { + volumes: parsed.volumes ?? {}, + plays: parsed.plays ?? {}, + totalPlays: parsed.totalPlays ?? 0, + categories: Array.isArray(parsed.categories) ? parsed.categories : [], + fileCategories: parsed.fileCategories ?? {}, + fileBadges: parsed.fileBadges ?? {}, + selectedChannels: parsed.selectedChannels ?? {}, + entranceSounds: parsed.entranceSounds ?? {}, + exitSounds: parsed.exitSounds ?? {} + } as PersistedState; + } + // 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); + const migrated: PersistedState = { + volumes: parsed.volumes ?? {}, + plays: parsed.plays ?? {}, + totalPlays: parsed.totalPlays ?? 0, + categories: Array.isArray(parsed.categories) ? parsed.categories : [], + fileCategories: parsed.fileCategories ?? {}, + fileBadges: parsed.fileBadges ?? {}, + selectedChannels: parsed.selectedChannels ?? {}, + entranceSounds: parsed.entranceSounds ?? {}, + exitSounds: parsed.exitSounds ?? {} + }; + try { + fs.mkdirSync(path.dirname(STATE_FILE_NEW), { recursive: true }); + fs.writeFileSync(STATE_FILE_NEW, JSON.stringify(migrated, null, 2), 'utf8'); + } catch {} + return migrated; + } + } catch {} + return { volumes: {}, plays: {}, totalPlays: 0 }; +} + +function writePersistedState(state: PersistedState): void { + try { + fs.mkdirSync(path.dirname(STATE_FILE_NEW), { recursive: true }); + fs.writeFileSync(STATE_FILE_NEW, JSON.stringify(state, null, 2), 'utf8'); + } catch (e) { + console.warn('Persisted state konnte nicht geschrieben werden:', e); + } +} + +const persistedState: PersistedState = readPersistedState(); + +// Debounced write: sammelt Schreibaufrufe und schreibt max. alle 2 Sekunden +let _writeTimer: ReturnType | 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; + writePersistedStateDebounced(); // Debounced: Play-Counter sind nicht kritisch + } 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'); + +// 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 }); + +// In-Memory PCM Cache: gecachte PCM-Dateien werden beim ersten Abspielen in den RAM geladen. +// Danach kein Disk-I/O mehr bei Cache-Hits -> nahezu instant playback. +const pcmMemoryCache = new Map(); +const PCM_MEMORY_CACHE_MAX_MB = Number(process.env.PCM_CACHE_MAX_MB ?? '512'); +let pcmMemoryCacheBytes = 0; + +function getPcmFromMemory(cachedPath: string): Buffer | null { + const buf = pcmMemoryCache.get(cachedPath); + if (buf) return buf; + // Erste Anfrage: von Disk in RAM laden + try { + const data = fs.readFileSync(cachedPath); + const newTotal = pcmMemoryCacheBytes + data.byteLength; + if (newTotal <= PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) { + pcmMemoryCache.set(cachedPath, data); + pcmMemoryCacheBytes = newTotal; + } + return data; + } catch { return null; } +} + +function invalidatePcmMemory(cachedPath: string): void { + const buf = pcmMemoryCache.get(cachedPath); + if (buf) { + pcmMemoryCacheBytes -= buf.byteLength; + pcmMemoryCache.delete(cachedPath); + } +} + +/** 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 {} invalidatePcmMemory(cacheFile); return null; } + } catch { return null; } + return cacheFile; +} + +/** Normalisiert eine Audiodatei via ffmpeg loudnorm und speichert das Ergebnis im Cache */ +function normalizeToCache(filePath: string): Promise { + 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 { + if (!NORMALIZE_ENABLE) return; + const t0 = Date.now(); + const allSounds = listAllSounds(); + + // Set aller erwarteten Cache-Keys + const expectedKeys = new Set(); + 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 { + 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 +void nacl.randomBytes(1); +console.log(generateDependencyReport()); + +// --- Discord Client --- +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.DirectMessages, + GatewayIntentBits.MessageContent, + ], + partials: [Partials.Channel] +}); + +type GuildAudioState = { + connection: VoiceConnection; + player: ReturnType; + guildId: string; + channelId: string; + currentResource?: AudioResource; + currentVolume: number; // 0..1 +}; +const guildAudioState = new Map(); +// Partymode: serverseitige Steuerung (global pro Guild) +const partyTimers = new Map(); +const partyActive = new Set(); +// Now-Playing: aktuell gespielter Sound pro Guild +const nowPlaying = new Map(); +// SSE-Klienten für Broadcasts (z.B. Partymode Status) +const sseClients = new Set(); +function sseBroadcast(payload: any) { + const data = `data: ${JSON.stringify(payload)}\n\n`; + for (const res of sseClients) { + try { res.write(data); } catch {} + } +} + +// 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 }); +} + +async function playFilePath(guildId: string, channelId: string, filePath: string, volume?: number, relativeKey?: string): Promise { + 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); + } + // 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 + }); + // Reuse bestehenden Player falls vorhanden + 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 {} + + // 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); + } + const useVolume = typeof volume === 'number' && Number.isFinite(volume) + ? Math.max(0, Math.min(1, volume)) + : (state.currentVolume ?? 1); + let resource: AudioResource; + if (NORMALIZE_ENABLE) { + const cachedPath = getNormCachePath(filePath); + if (cachedPath) { + // Cache-Hit: PCM aus RAM lesen (kein Disk-I/O, instant) + const pcmBuf = getPcmFromMemory(cachedPath); + if (pcmBuf) { + const useInline = useVolume !== 1; + resource = createAudioResource(Readable.from(pcmBuf), { + inlineVolume: useInline, + inputType: StreamType.Raw + }); + } else { + // Fallback: Stream von Disk + const pcmStream = fs.createReadStream(cachedPath, { highWaterMark: 256 * 1024 }); + 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)}`); + // In Memory-Cache laden fuer naechsten Aufruf + try { + const buf = fs.readFileSync(cacheFile); + if (pcmMemoryCacheBytes + buf.byteLength <= PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) { + pcmMemoryCache.set(cacheFile, buf); + pcmMemoryCacheBytes += buf.byteLength; + } + } catch {} + }); + 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 }); + } + if (resource.volume) resource.volume.setVolume(useVolume); + state.player.stop(); + 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); +} + +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' + + '?list - listet alle Audio-Dateien (mp3/wav)\n' + + '?entrance | remove - setze oder entferne deinen Entrance-Sound\n' + + '?exit | remove - setze oder entferne deinen Exit-Sound\n' + ); + return; + } + if (cmd === '?list') { + const files = fs + .readdirSync(SOUNDS_DIR) + .filter(f => { const l = f.toLowerCase(); return l.endsWith('.mp3') || l.endsWith('.wav'); }); + await reply(files.length ? files.join('\n') : 'Keine Dateien gefunden.'); + return; + } + if (cmd === '?entrance') { + 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 | 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; + } + 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; } + 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 {} + await reply(`Entrance-Sound gesetzt: ${resolve}`); return; + } + if (cmd === '?exit') { + 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 | 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; + } + 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; } + 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 {} + await reply(`Exit-Sound gesetzt: ${resolve}`); return; + } + await reply('Unbekannter Command. Nutze ?help.'); +} + +async function ensureConnectionReady(connection: VoiceConnection, channelId: string, guildId: string, guild: any): Promise { + // Versuch 1: Warten ob bestehende Connection ready wird + 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...`); + } + + // Versuch 2: Rejoin + 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.warn(`${new Date().toISOString()} | VoiceConnection still not ready after rejoin`); + } + + // Versuch 3: Komplett neu verbinden + try { connection.destroy(); } catch {} + guildAudioState.delete(guildId); + + const newConn = joinVoiceChannel({ + channelId, + guildId, + adapterCreator: guild.voiceAdapterCreator as any, + selfMute: false, + selfDeaf: false + }); + try { + await entersState(newConn, VoiceConnectionStatus.Ready, 15_000); + console.log(`${new Date().toISOString()} | VoiceConnection ready after fresh join`); + return newConn; + } catch (e3) { + console.error(`${new Date().toISOString()} | VoiceConnection failed after all attempts, cleaning up`); + try { newConn.destroy(); } catch {} + guildAudioState.delete(guildId); + throw new Error('Voice connection failed after 3 attempts'); + } +} + +function attachVoiceLifecycle(state: GuildAudioState, guild: any) { + const { connection } = state; + // Mehrfach-Registrierung verhindern + if ((connection as any).__lifecycleAttached) return; + try { (connection as any).setMaxListeners?.(0); } catch {} + + // Retry-Tracking um Endlosschleife zu verhindern + let reconnectAttempts = 0; + const MAX_RECONNECT_ATTEMPTS = 3; + let isReconnecting = false; + + connection.on('stateChange', async (oldS: any, newS: any) => { + console.log(`${new Date().toISOString()} | VoiceConnection: ${oldS.status} -> ${newS.status}`); + + // Ready zurückgesetzt -> Retry-Counter reset + if (newS.status === VoiceConnectionStatus.Ready) { + reconnectAttempts = 0; + isReconnecting = false; + return; + } + + // Re-Entranz verhindern: wenn schon ein Reconnect läuft, nicht nochmal starten + if (isReconnecting) return; + + 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 { + if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + reconnectAttempts++; + console.warn(`${new Date().toISOString()} | Disconnected rejoin attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`); + connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); + } else { + console.error(`${new Date().toISOString()} | Max reconnect attempts reached from Disconnected, destroying and rejoining fresh`); + reconnectAttempts = 0; + try { connection.destroy(); } catch {} + 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.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) { + isReconnecting = true; + try { + await entersState(connection, VoiceConnectionStatus.Ready, 15_000); + // Ready wird oben im Handler behandelt + } catch (e) { + reconnectAttempts++; + if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + const backoffMs = reconnectAttempts * 2_000; + console.warn(`${new Date().toISOString()} | Voice not ready from ${newS.status}, attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}, backoff ${backoffMs}ms`); + await new Promise(r => setTimeout(r, backoffMs)); + isReconnecting = false; + connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); + } else { + console.error(`${new Date().toISOString()} | Max reconnect attempts reached, destroying and rejoining fresh`); + reconnectAttempts = 0; + isReconnecting = false; + try { connection.destroy(); } catch {} + 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); + } + } + } + } catch (e) { + isReconnecting = false; + console.error(`${new Date().toISOString()} | Voice lifecycle handler error`, e); + } + }); + (connection as any).__lifecycleAttached = true; +} + +client.once(Events.ClientReady, () => { + console.log(`Bot eingeloggt als ${client.user?.tag}`); +}); + +// Voice State Updates: Entrance/Exit +client.on(Events.VoiceStateUpdate, async (oldState: VoiceState, newState: VoiceState) => { + try { + const userId = (newState.id || oldState.id) as string; + if (!userId) return; + // Eigene Events ignorieren + if (userId === client.user?.id) return; + const guildId = (newState.guild?.id || oldState.guild?.id) as string; + if (!guildId) return; + + const before = oldState.channelId; + const after = newState.channelId; + console.log(`${new Date().toISOString()} | VoiceStateUpdate user=${userId} before=${before ?? '-'} after=${after ?? '-'}`); + + // 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}`); + 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)) { + try { + // Dem Channel beitreten und Sound spielen + await playFilePath(guildId, after, abs, undefined, rel); + console.log(`${new Date().toISOString()} | Entrance played for ${userId}: ${rel}`); + } catch (e) { console.warn('Entrance play error', e); } + } + } + } + // 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}`); + 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)) { + try { + await playFilePath(guildId, before, abs, undefined, rel); + console.log(`${new Date().toISOString()} | Exit played for ${userId}: ${rel}`); + } catch (e) { console.warn('Exit play error', e); } + } + } + } 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}`); + } + } catch (e) { + console.warn('VoiceStateUpdate entrance/exit handling error', e); + } +}); + +client.on(Events.MessageCreate, async (message: Message) => { + try { + if (message.author?.bot) return; + // Commands überall annehmen (inkl. DMs) + const content = (message.content || '').trim(); + if (content.startsWith('?')) { + await handleCommand(message, content); + return; + } + // Dateiuploads nur per DM + if (!message.channel?.isDMBased?.()) return; + if (message.attachments.size === 0) return; + + for (const [, attachment] of message.attachments) { + const name = attachment.name ?? 'upload'; + const lower = name.toLowerCase(); + if (!(lower.endsWith('.mp3') || lower.endsWith('.wav'))) continue; + + 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; + const ext = path.parse(safeName).ext || (lower.endsWith('.wav') ? '.wav' : '.mp3'); + 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)); + // 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) { + 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) => { + 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(); + 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 { + 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')) }); +}); + +app.get('/api/sounds', (req: Request, res: Response) => { + const q = String(req.query.q ?? '').toLowerCase(); + const folderFilter = typeof req.query.folder === 'string' ? (req.query.folder as string) : '__all__'; + const categoryFilter = typeof (req.query as any).categoryId === 'string' ? String((req.query as any).categoryId) : undefined; + const fuzzyParam = String((req.query as any).fuzzy ?? '0'); + const useFuzzy = fuzzyParam === '1' || fuzzyParam === 'true'; + + const allItems = listAllSounds(); + + // Ordner-Statistik aus allItems ableiten + const folderCounts = new Map(); + 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 }); + } + + // 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)); + let itemsByFolder = allItems; + if (folderFilter !== '__all__') { + 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)); + } + } + // 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) { + 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)); + } + } + + const total = allItems.length; + const recentCount = Math.min(10, total); + // 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 }>; + + const foldersOut = [ + { key: '__all__', name: 'Alle', count: total }, + { key: '__recent__', name: 'Neu', count: recentCount }, + ...(top3.length ? [{ key: '__top3__', name: 'Most Played (3)', count: top3.length }] : []), + ...folders + ]; + // isRecent-Flag für UI (Top 5 der neuesten) + // Kategorie-Filter (virtuell) anwenden, wenn gesetzt + let result = filteredItems; + if (categoryFilter) { + const fc = persistedState.fileCategories ?? {}; + result = result.filter((it) => { + const key = it.relativePath ?? it.fileName; + const cats = fc[key] ?? []; + return cats.includes(categoryFilter); + }); + } + if (folderFilter === '__top3__') { + const keys = new Set(top3.map(t => t.key.split(':')[1])); + result = allItems.filter(i => keys.has(i.relativePath ?? i.fileName)); + } + + // 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; + }); + + res.json({ items: withRecentFlag, total, folders: foldersOut, categories: persistedState.categories ?? [], fileCategories: persistedState.fileCategories ?? {} }); +}); + +// --- 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) { + 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' }); + } + } 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' }); + 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 = 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 ?? [] }); +}); + +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 }); +}); + +// 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 }); +}); + +// 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 }); +}); + +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); + const result: Array<{ guildId: string; guildName: string; channelId: string; channelName: string; selected?: boolean }> = []; + 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) { + const sel = getSelectedChannelForGuild(guild.id); + result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name, selected: sel === ch.id }); + } + } + } + result.sort((a, b) => a.guildName.localeCompare(b.guildName) || a.channelName.localeCompare(b.channelName)); + res.json(result); +}); + +// 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' }); + } +}); + +app.post('/api/play', async (req: Request, res: Response) => { + try { + const { soundName, guildId, channelId, volume, folder, relativePath } = req.body as { + soundName?: string; + guildId?: string; + channelId?: string; + volume?: number; // 0..1 + folder?: string; // optional subfolder key + relativePath?: string; // optional direct relative path + }; + if (!soundName || !guildId || !channelId) return res.status(400).json({ error: 'soundName, guildId, channelId erforderlich' }); + + let filePath: string; + if (relativePath) filePath = path.join(SOUNDS_DIR, relativePath); + 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; + } + if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Sound nicht gefunden' }); + + // 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); + return res.status(500).json({ error: err?.message ?? 'Unbekannter Fehler' }); + } +}); + +// 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) { + // Kein aktiver Player: nur persistieren für nächste Wiedergabe + persistedState.volumes[guildId] = safeVolume; + writePersistedState(persistedState); + // Broadcast neue Lautstärke an alle Clients + sseBroadcast({ type: 'volume', guildId, volume: safeVolume }); + return res.json({ ok: true, volume: safeVolume, persistedOnly: true }); + } + state.currentVolume = safeVolume; + if (state.currentResource?.volume) { + state.currentResource.volume.setVolume(safeVolume); + console.log(`${new Date().toISOString()} | live setVolume(${safeVolume}) guild=${guildId}`); + } + persistedState.volumes[guildId] = safeVolume; + writePersistedState(persistedState); + // Broadcast neue Lautstärke an alle Clients + sseBroadcast({ type: 'volume', guildId, volume: safeVolume }); + 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' }); + } +}); + +// 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 }); +}); + +// 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); + // 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); + if (t) clearTimeout(t); + partyTimers.delete(guildId); + partyActive.delete(guildId); + sseBroadcast({ type: 'party', guildId, active: false }); + } 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(); + // Broadcast Status + sseBroadcast({ type: 'party', guildId, active: true, channelId }); +} + +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); + sseBroadcast({ type: 'party', guildId: id, active: false }); + return res.json({ ok: true }); + } catch (e: any) { + console.error('party/stop error', e); + return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' }); + } +}); + +// 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 + try { + 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 + const ping = setInterval(() => { try { res.write(':\n\n'); } catch {} }, 15_000); + + sseClients.add(res); + req.on('close', () => { + sseClients.delete(res); + clearInterval(ping); + try { res.end(); } catch {} + }); +}); + +// --- 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)) { + app.use(express.static(webDistPath)); + app.get('/{*splat}', (_req, res) => { + res.sendFile(path.join(webDistPath, 'index.html')); + }); +} + +process.on('uncaughtException', (err) => { + console.error(`FATAL uncaughtException:`, err); + process.exit(1); +}); +process.on('unhandledRejection', (reason) => { + console.error(`FATAL unhandledRejection:`, reason); +}); + +app.listen(PORT, () => { + console.log(`Server läuft auf http://0.0.0.0:${PORT}`); + + // Vollständige Cache-Synchronisation beim Start (Hintergrund) + syncNormCache(); +}); + + + + From 4884691e7d3c69e9d7675c6c9e195a08017781bc Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 5 Mar 2026 15:20:20 +0100 Subject: [PATCH 16/35] perf: static ffmpeg binary statt apt-get install (~600MB gespart) - Neuer multi-stage: ffmpeg-fetch laedt statisch gelinkte ffmpeg/ffprobe Binary - Quelle: yt-dlp/FFmpeg-Builds (GPL, alle Codecs enthalten) - Runtime braucht kein apt-get install ffmpeg mehr (kein libavcodec etc.) - curl wird nach yt-dlp Download wieder entfernt (apt purge) - Erwartete Image-Groesse: ~280MB statt ~890MB --- Dockerfile | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 21cd07d..a6d5a87 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,10 @@ RUN npm run build # 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 +# --- Static ffmpeg binary (kein apt-get, keine Codec-Libs noetig) --- +FROM debian:bookworm-slim AS ffmpeg-fetch +RUN apt-get update && apt-get install -y curl xz-utils ca-certificates && rm -rf /var/lib/apt/lists/* && curl -L https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz -o /tmp/ffmpeg.tar.xz && mkdir -p /tmp/ffmpeg && tar -xJf /tmp/ffmpeg.tar.xz -C /tmp/ffmpeg --strip-components=2 --wildcards '*/bin/ffmpeg' '*/bin/ffprobe' && chmod +x /tmp/ffmpeg/ffmpeg /tmp/ffmpeg/ffprobe && rm /tmp/ffmpeg.tar.xz + # --- Runtime image --- FROM node:24-slim AS runtime WORKDIR /app @@ -31,10 +35,12 @@ ENV NODE_ENV=production ENV PORT=8080 ENV SOUNDS_DIR=/data/sounds -RUN apt-get update && apt-get install -y ffmpeg curl ca-certificates && rm -rf /var/lib/apt/lists/* \ - && curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp \ - && chmod a+rx /usr/local/bin/yt-dlp \ - && yt-dlp --version || true +# Nur ca-certificates fuer HTTPS + yt-dlp (kein ffmpeg via apt!) +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && chmod a+rx /usr/local/bin/yt-dlp && apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* + +# Statische ffmpeg/ffprobe Binaries aus fetch-stage kopieren +COPY --from=ffmpeg-fetch /tmp/ffmpeg/ffmpeg /usr/local/bin/ffmpeg +COPY --from=ffmpeg-fetch /tmp/ffmpeg/ffprobe /usr/local/bin/ffprobe COPY --from=server-build /app/server/dist ./server/dist COPY --from=server-build /app/server/node_modules ./server/node_modules @@ -44,6 +50,3 @@ COPY --from=web-build /app/web/dist ./web/dist EXPOSE 8080 VOLUME ["/data/sounds"] CMD ["node", "server/dist/index.js"] - - - From 546d28c8fadffcd7a43bcaeb1698a5c650898e1f Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 5 Mar 2026 15:31:25 +0100 Subject: [PATCH 17/35] fix: Dockerfile ffmpeg-fetch + drop ffprobe + suppress TimeoutNegativeWarning - ffmpeg-fetch Stage war versehentlich leer (sed-Fehler) - jetzt korrekt - ffprobe entfernt (wird nirgends benutzt, spart ~200MB) - TimeoutNegativeWarning aus @discordjs/voice unterdrueckt (Node 24 Kompatibilitaet) --- Dockerfile | 22 +- server/src/index.ts | 3209 ++++++++++++++++++++++--------------------- 2 files changed, 1620 insertions(+), 1611 deletions(-) diff --git a/Dockerfile b/Dockerfile index a6d5a87..c4bc476 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,6 @@ WORKDIR /app/web COPY web/package*.json ./ RUN npm install --no-audit --no-fund COPY web/ . -# Umgebungsvariable für React Build verfügbar machen (Vite liest nur VITE_*) ARG VITE_BUILD_CHANNEL=stable ARG VITE_APP_VERSION=1.1.0 ENV VITE_BUILD_CHANNEL=$VITE_BUILD_CHANNEL @@ -21,12 +20,18 @@ COPY server/package*.json ./ RUN npm install --no-audit --no-fund COPY server/ . RUN npm run build -# 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 -# --- Static ffmpeg binary (kein apt-get, keine Codec-Libs noetig) --- +# --- Static ffmpeg binary (nur ffmpeg, kein ffprobe - wird nicht benutzt) --- FROM debian:bookworm-slim AS ffmpeg-fetch -RUN apt-get update && apt-get install -y curl xz-utils ca-certificates && rm -rf /var/lib/apt/lists/* && curl -L https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz -o /tmp/ffmpeg.tar.xz && mkdir -p /tmp/ffmpeg && tar -xJf /tmp/ffmpeg.tar.xz -C /tmp/ffmpeg --strip-components=2 --wildcards '*/bin/ffmpeg' '*/bin/ffprobe' && chmod +x /tmp/ffmpeg/ffmpeg /tmp/ffmpeg/ffprobe && rm /tmp/ffmpeg.tar.xz +RUN apt-get update && apt-get install -y --no-install-recommends curl xz-utils ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && curl -L https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz \ + -o /tmp/ffmpeg.tar.xz \ + && mkdir -p /tmp/ffmpeg \ + && tar -xJf /tmp/ffmpeg.tar.xz -C /tmp/ffmpeg --strip-components=2 --wildcards "*/bin/ffmpeg" \ + && chmod +x /tmp/ffmpeg/ffmpeg \ + && rm /tmp/ffmpeg.tar.xz # --- Runtime image --- FROM node:24-slim AS runtime @@ -35,13 +40,12 @@ ENV NODE_ENV=production ENV PORT=8080 ENV SOUNDS_DIR=/data/sounds -# Nur ca-certificates fuer HTTPS + yt-dlp (kein ffmpeg via apt!) -RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && chmod a+rx /usr/local/bin/yt-dlp && apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \ + && curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp \ + && chmod a+rx /usr/local/bin/yt-dlp \ + && apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* -# Statische ffmpeg/ffprobe Binaries aus fetch-stage kopieren COPY --from=ffmpeg-fetch /tmp/ffmpeg/ffmpeg /usr/local/bin/ffmpeg -COPY --from=ffmpeg-fetch /tmp/ffmpeg/ffprobe /usr/local/bin/ffprobe - COPY --from=server-build /app/server/dist ./server/dist COPY --from=server-build /app/server/node_modules ./server/node_modules COPY --from=server-build /app/server/package.json ./server/package.json diff --git a/server/src/index.ts b/server/src/index.ts index b291e02..3c536c4 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,1602 +1,1607 @@ -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 cors from 'cors'; -import crypto from 'node:crypto'; -import { Client, GatewayIntentBits, Partials, ChannelType, Events, type Message, VoiceState } from 'discord.js'; -import { - joinVoiceChannel, - createAudioPlayer, - createAudioResource, - AudioPlayerStatus, - NoSubscriberBehavior, - getVoiceConnection, - type VoiceConnection, - type AudioResource, - StreamType, - generateDependencyReport, - entersState, - VoiceConnectionStatus -} from '@discordjs/voice'; -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, Readable } from 'node:stream'; - -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 ?? ''; -const ADMIN_PWD = process.env.ADMIN_PWD ?? ''; -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 }); - -// Persistenter Zustand: Lautstärke/Plays + Kategorien -type Category = { id: string; name: string; color?: string; sort?: number }; - type PersistedState = { - volumes: Record; - plays: Record; - totalPlays: number; - categories?: Category[]; - fileCategories?: Record; // relPath or fileName -> categoryIds[] - fileBadges?: Record; // relPath or fileName -> custom badges (emoji/text) - selectedChannels?: Record; // guildId -> channelId (serverweite Auswahl) - entranceSounds?: Record; // userId -> relativePath or fileName - exitSounds?: Record; // userId -> relativePath or fileName -}; -// 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'); - -function readPersistedState(): PersistedState { - try { - // 1) Bevorzugt neuen Speicherort lesen - if (fs.existsSync(STATE_FILE_NEW)) { - const raw = fs.readFileSync(STATE_FILE_NEW, 'utf8'); - const parsed = JSON.parse(raw); - return { - volumes: parsed.volumes ?? {}, - plays: parsed.plays ?? {}, - totalPlays: parsed.totalPlays ?? 0, - categories: Array.isArray(parsed.categories) ? parsed.categories : [], - fileCategories: parsed.fileCategories ?? {}, - fileBadges: parsed.fileBadges ?? {}, - selectedChannels: parsed.selectedChannels ?? {}, - entranceSounds: parsed.entranceSounds ?? {}, - exitSounds: parsed.exitSounds ?? {} - } as PersistedState; - } - // 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); - const migrated: PersistedState = { - volumes: parsed.volumes ?? {}, - plays: parsed.plays ?? {}, - totalPlays: parsed.totalPlays ?? 0, - categories: Array.isArray(parsed.categories) ? parsed.categories : [], - fileCategories: parsed.fileCategories ?? {}, - fileBadges: parsed.fileBadges ?? {}, - selectedChannels: parsed.selectedChannels ?? {}, - entranceSounds: parsed.entranceSounds ?? {}, - exitSounds: parsed.exitSounds ?? {} - }; - try { - fs.mkdirSync(path.dirname(STATE_FILE_NEW), { recursive: true }); - fs.writeFileSync(STATE_FILE_NEW, JSON.stringify(migrated, null, 2), 'utf8'); - } catch {} - return migrated; - } - } catch {} - return { volumes: {}, plays: {}, totalPlays: 0 }; -} - -function writePersistedState(state: PersistedState): void { - try { - fs.mkdirSync(path.dirname(STATE_FILE_NEW), { recursive: true }); - fs.writeFileSync(STATE_FILE_NEW, JSON.stringify(state, null, 2), 'utf8'); - } catch (e) { - console.warn('Persisted state konnte nicht geschrieben werden:', e); - } -} - -const persistedState: PersistedState = readPersistedState(); - -// Debounced write: sammelt Schreibaufrufe und schreibt max. alle 2 Sekunden -let _writeTimer: ReturnType | 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; - writePersistedStateDebounced(); // Debounced: Play-Counter sind nicht kritisch - } 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'); - -// 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 }); - -// In-Memory PCM Cache: gecachte PCM-Dateien werden beim ersten Abspielen in den RAM geladen. -// Danach kein Disk-I/O mehr bei Cache-Hits -> nahezu instant playback. -const pcmMemoryCache = new Map(); -const PCM_MEMORY_CACHE_MAX_MB = Number(process.env.PCM_CACHE_MAX_MB ?? '512'); -let pcmMemoryCacheBytes = 0; - -function getPcmFromMemory(cachedPath: string): Buffer | null { - const buf = pcmMemoryCache.get(cachedPath); - if (buf) return buf; - // Erste Anfrage: von Disk in RAM laden - try { - const data = fs.readFileSync(cachedPath); - const newTotal = pcmMemoryCacheBytes + data.byteLength; - if (newTotal <= PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) { - pcmMemoryCache.set(cachedPath, data); - pcmMemoryCacheBytes = newTotal; - } - return data; - } catch { return null; } -} - -function invalidatePcmMemory(cachedPath: string): void { - const buf = pcmMemoryCache.get(cachedPath); - if (buf) { - pcmMemoryCacheBytes -= buf.byteLength; - pcmMemoryCache.delete(cachedPath); - } -} - -/** 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 {} invalidatePcmMemory(cacheFile); return null; } - } catch { return null; } - return cacheFile; -} - -/** Normalisiert eine Audiodatei via ffmpeg loudnorm und speichert das Ergebnis im Cache */ -function normalizeToCache(filePath: string): Promise { - 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 { - if (!NORMALIZE_ENABLE) return; - const t0 = Date.now(); - const allSounds = listAllSounds(); - - // Set aller erwarteten Cache-Keys - const expectedKeys = new Set(); - 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 { - 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 -void nacl.randomBytes(1); -console.log(generateDependencyReport()); - -// --- Discord Client --- -const client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildVoiceStates, - GatewayIntentBits.DirectMessages, - GatewayIntentBits.MessageContent, - ], - partials: [Partials.Channel] -}); - -type GuildAudioState = { - connection: VoiceConnection; - player: ReturnType; - guildId: string; - channelId: string; - currentResource?: AudioResource; - currentVolume: number; // 0..1 -}; -const guildAudioState = new Map(); -// Partymode: serverseitige Steuerung (global pro Guild) -const partyTimers = new Map(); -const partyActive = new Set(); -// Now-Playing: aktuell gespielter Sound pro Guild -const nowPlaying = new Map(); -// SSE-Klienten für Broadcasts (z.B. Partymode Status) -const sseClients = new Set(); -function sseBroadcast(payload: any) { - const data = `data: ${JSON.stringify(payload)}\n\n`; - for (const res of sseClients) { - try { res.write(data); } catch {} - } -} - -// 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 }); -} - -async function playFilePath(guildId: string, channelId: string, filePath: string, volume?: number, relativeKey?: string): Promise { - 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); - } - // 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 - }); - // Reuse bestehenden Player falls vorhanden - 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 {} - - // 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); - } - const useVolume = typeof volume === 'number' && Number.isFinite(volume) - ? Math.max(0, Math.min(1, volume)) - : (state.currentVolume ?? 1); - let resource: AudioResource; - if (NORMALIZE_ENABLE) { - const cachedPath = getNormCachePath(filePath); - if (cachedPath) { - // Cache-Hit: PCM aus RAM lesen (kein Disk-I/O, instant) - const pcmBuf = getPcmFromMemory(cachedPath); - if (pcmBuf) { - const useInline = useVolume !== 1; - resource = createAudioResource(Readable.from(pcmBuf), { - inlineVolume: useInline, - inputType: StreamType.Raw - }); - } else { - // Fallback: Stream von Disk - const pcmStream = fs.createReadStream(cachedPath, { highWaterMark: 256 * 1024 }); - 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)}`); - // In Memory-Cache laden fuer naechsten Aufruf - try { - const buf = fs.readFileSync(cacheFile); - if (pcmMemoryCacheBytes + buf.byteLength <= PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) { - pcmMemoryCache.set(cacheFile, buf); - pcmMemoryCacheBytes += buf.byteLength; - } - } catch {} - }); - 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 }); - } - if (resource.volume) resource.volume.setVolume(useVolume); - state.player.stop(); - 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); -} - -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' + - '?list - listet alle Audio-Dateien (mp3/wav)\n' + - '?entrance | remove - setze oder entferne deinen Entrance-Sound\n' + - '?exit | remove - setze oder entferne deinen Exit-Sound\n' - ); - return; - } - if (cmd === '?list') { - const files = fs - .readdirSync(SOUNDS_DIR) - .filter(f => { const l = f.toLowerCase(); return l.endsWith('.mp3') || l.endsWith('.wav'); }); - await reply(files.length ? files.join('\n') : 'Keine Dateien gefunden.'); - return; - } - if (cmd === '?entrance') { - 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 | 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; - } - 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; } - 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 {} - await reply(`Entrance-Sound gesetzt: ${resolve}`); return; - } - if (cmd === '?exit') { - 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 | 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; - } - 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; } - 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 {} - await reply(`Exit-Sound gesetzt: ${resolve}`); return; - } - await reply('Unbekannter Command. Nutze ?help.'); -} - -async function ensureConnectionReady(connection: VoiceConnection, channelId: string, guildId: string, guild: any): Promise { - // Versuch 1: Warten ob bestehende Connection ready wird - 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...`); - } - - // Versuch 2: Rejoin - 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.warn(`${new Date().toISOString()} | VoiceConnection still not ready after rejoin`); - } - - // Versuch 3: Komplett neu verbinden - try { connection.destroy(); } catch {} - guildAudioState.delete(guildId); - - const newConn = joinVoiceChannel({ - channelId, - guildId, - adapterCreator: guild.voiceAdapterCreator as any, - selfMute: false, - selfDeaf: false - }); - try { - await entersState(newConn, VoiceConnectionStatus.Ready, 15_000); - console.log(`${new Date().toISOString()} | VoiceConnection ready after fresh join`); - return newConn; - } catch (e3) { - console.error(`${new Date().toISOString()} | VoiceConnection failed after all attempts, cleaning up`); - try { newConn.destroy(); } catch {} - guildAudioState.delete(guildId); - throw new Error('Voice connection failed after 3 attempts'); - } -} - -function attachVoiceLifecycle(state: GuildAudioState, guild: any) { - const { connection } = state; - // Mehrfach-Registrierung verhindern - if ((connection as any).__lifecycleAttached) return; - try { (connection as any).setMaxListeners?.(0); } catch {} - - // Retry-Tracking um Endlosschleife zu verhindern - let reconnectAttempts = 0; - const MAX_RECONNECT_ATTEMPTS = 3; - let isReconnecting = false; - - connection.on('stateChange', async (oldS: any, newS: any) => { - console.log(`${new Date().toISOString()} | VoiceConnection: ${oldS.status} -> ${newS.status}`); - - // Ready zurückgesetzt -> Retry-Counter reset - if (newS.status === VoiceConnectionStatus.Ready) { - reconnectAttempts = 0; - isReconnecting = false; - return; - } - - // Re-Entranz verhindern: wenn schon ein Reconnect läuft, nicht nochmal starten - if (isReconnecting) return; - - 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 { - if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { - reconnectAttempts++; - console.warn(`${new Date().toISOString()} | Disconnected rejoin attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`); - connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); - } else { - console.error(`${new Date().toISOString()} | Max reconnect attempts reached from Disconnected, destroying and rejoining fresh`); - reconnectAttempts = 0; - try { connection.destroy(); } catch {} - 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.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) { - isReconnecting = true; - try { - await entersState(connection, VoiceConnectionStatus.Ready, 15_000); - // Ready wird oben im Handler behandelt - } catch (e) { - reconnectAttempts++; - if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { - const backoffMs = reconnectAttempts * 2_000; - console.warn(`${new Date().toISOString()} | Voice not ready from ${newS.status}, attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}, backoff ${backoffMs}ms`); - await new Promise(r => setTimeout(r, backoffMs)); - isReconnecting = false; - connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); - } else { - console.error(`${new Date().toISOString()} | Max reconnect attempts reached, destroying and rejoining fresh`); - reconnectAttempts = 0; - isReconnecting = false; - try { connection.destroy(); } catch {} - 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); - } - } - } - } catch (e) { - isReconnecting = false; - console.error(`${new Date().toISOString()} | Voice lifecycle handler error`, e); - } - }); - (connection as any).__lifecycleAttached = true; -} - -client.once(Events.ClientReady, () => { - console.log(`Bot eingeloggt als ${client.user?.tag}`); -}); - -// Voice State Updates: Entrance/Exit -client.on(Events.VoiceStateUpdate, async (oldState: VoiceState, newState: VoiceState) => { - try { - const userId = (newState.id || oldState.id) as string; - if (!userId) return; - // Eigene Events ignorieren - if (userId === client.user?.id) return; - const guildId = (newState.guild?.id || oldState.guild?.id) as string; - if (!guildId) return; - - const before = oldState.channelId; - const after = newState.channelId; - console.log(`${new Date().toISOString()} | VoiceStateUpdate user=${userId} before=${before ?? '-'} after=${after ?? '-'}`); - - // 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}`); - 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)) { - try { - // Dem Channel beitreten und Sound spielen - await playFilePath(guildId, after, abs, undefined, rel); - console.log(`${new Date().toISOString()} | Entrance played for ${userId}: ${rel}`); - } catch (e) { console.warn('Entrance play error', e); } - } - } - } - // 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}`); - 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)) { - try { - await playFilePath(guildId, before, abs, undefined, rel); - console.log(`${new Date().toISOString()} | Exit played for ${userId}: ${rel}`); - } catch (e) { console.warn('Exit play error', e); } - } - } - } 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}`); - } - } catch (e) { - console.warn('VoiceStateUpdate entrance/exit handling error', e); - } -}); - -client.on(Events.MessageCreate, async (message: Message) => { - try { - if (message.author?.bot) return; - // Commands überall annehmen (inkl. DMs) - const content = (message.content || '').trim(); - if (content.startsWith('?')) { - await handleCommand(message, content); - return; - } - // Dateiuploads nur per DM - if (!message.channel?.isDMBased?.()) return; - if (message.attachments.size === 0) return; - - for (const [, attachment] of message.attachments) { - const name = attachment.name ?? 'upload'; - const lower = name.toLowerCase(); - if (!(lower.endsWith('.mp3') || lower.endsWith('.wav'))) continue; - - 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; - const ext = path.parse(safeName).ext || (lower.endsWith('.wav') ? '.wav' : '.mp3'); - 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)); - // 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) { - 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) => { - 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(); - 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 { - 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')) }); -}); - -app.get('/api/sounds', (req: Request, res: Response) => { - const q = String(req.query.q ?? '').toLowerCase(); - const folderFilter = typeof req.query.folder === 'string' ? (req.query.folder as string) : '__all__'; - const categoryFilter = typeof (req.query as any).categoryId === 'string' ? String((req.query as any).categoryId) : undefined; - const fuzzyParam = String((req.query as any).fuzzy ?? '0'); - const useFuzzy = fuzzyParam === '1' || fuzzyParam === 'true'; - - const allItems = listAllSounds(); - - // Ordner-Statistik aus allItems ableiten - const folderCounts = new Map(); - 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 }); - } - - // 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)); - let itemsByFolder = allItems; - if (folderFilter !== '__all__') { - 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)); - } - } - // 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) { - 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)); - } - } - - const total = allItems.length; - const recentCount = Math.min(10, total); - // 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 }>; - - const foldersOut = [ - { key: '__all__', name: 'Alle', count: total }, - { key: '__recent__', name: 'Neu', count: recentCount }, - ...(top3.length ? [{ key: '__top3__', name: 'Most Played (3)', count: top3.length }] : []), - ...folders - ]; - // isRecent-Flag für UI (Top 5 der neuesten) - // Kategorie-Filter (virtuell) anwenden, wenn gesetzt - let result = filteredItems; - if (categoryFilter) { - const fc = persistedState.fileCategories ?? {}; - result = result.filter((it) => { - const key = it.relativePath ?? it.fileName; - const cats = fc[key] ?? []; - return cats.includes(categoryFilter); - }); - } - if (folderFilter === '__top3__') { - const keys = new Set(top3.map(t => t.key.split(':')[1])); - result = allItems.filter(i => keys.has(i.relativePath ?? i.fileName)); - } - - // 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; - }); - - res.json({ items: withRecentFlag, total, folders: foldersOut, categories: persistedState.categories ?? [], fileCategories: persistedState.fileCategories ?? {} }); -}); - -// --- 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) { - 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' }); - } - } 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' }); - 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 = 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 ?? [] }); -}); - -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 }); -}); - -// 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 }); -}); - -// 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 }); -}); - -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); - const result: Array<{ guildId: string; guildName: string; channelId: string; channelName: string; selected?: boolean }> = []; - 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) { - const sel = getSelectedChannelForGuild(guild.id); - result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name, selected: sel === ch.id }); - } - } - } - result.sort((a, b) => a.guildName.localeCompare(b.guildName) || a.channelName.localeCompare(b.channelName)); - res.json(result); -}); - -// 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' }); - } -}); - -app.post('/api/play', async (req: Request, res: Response) => { - try { - const { soundName, guildId, channelId, volume, folder, relativePath } = req.body as { - soundName?: string; - guildId?: string; - channelId?: string; - volume?: number; // 0..1 - folder?: string; // optional subfolder key - relativePath?: string; // optional direct relative path - }; - if (!soundName || !guildId || !channelId) return res.status(400).json({ error: 'soundName, guildId, channelId erforderlich' }); - - let filePath: string; - if (relativePath) filePath = path.join(SOUNDS_DIR, relativePath); - 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; - } - if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Sound nicht gefunden' }); - - // 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); - return res.status(500).json({ error: err?.message ?? 'Unbekannter Fehler' }); - } -}); - -// 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) { - // Kein aktiver Player: nur persistieren für nächste Wiedergabe - persistedState.volumes[guildId] = safeVolume; - writePersistedState(persistedState); - // Broadcast neue Lautstärke an alle Clients - sseBroadcast({ type: 'volume', guildId, volume: safeVolume }); - return res.json({ ok: true, volume: safeVolume, persistedOnly: true }); - } - state.currentVolume = safeVolume; - if (state.currentResource?.volume) { - state.currentResource.volume.setVolume(safeVolume); - console.log(`${new Date().toISOString()} | live setVolume(${safeVolume}) guild=${guildId}`); - } - persistedState.volumes[guildId] = safeVolume; - writePersistedState(persistedState); - // Broadcast neue Lautstärke an alle Clients - sseBroadcast({ type: 'volume', guildId, volume: safeVolume }); - 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' }); - } -}); - -// 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 }); -}); - -// 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); - // 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); - if (t) clearTimeout(t); - partyTimers.delete(guildId); - partyActive.delete(guildId); - sseBroadcast({ type: 'party', guildId, active: false }); - } 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(); - // Broadcast Status - sseBroadcast({ type: 'party', guildId, active: true, channelId }); -} - -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); - sseBroadcast({ type: 'party', guildId: id, active: false }); - return res.json({ ok: true }); - } catch (e: any) { - console.error('party/stop error', e); - return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' }); - } -}); - -// 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 - try { - 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 - const ping = setInterval(() => { try { res.write(':\n\n'); } catch {} }, 15_000); - - sseClients.add(res); - req.on('close', () => { - sseClients.delete(res); - clearInterval(ping); - try { res.end(); } catch {} - }); -}); - -// --- 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)) { - app.use(express.static(webDistPath)); - app.get('/{*splat}', (_req, res) => { - res.sendFile(path.join(webDistPath, 'index.html')); - }); -} - -process.on('uncaughtException', (err) => { - console.error(`FATAL uncaughtException:`, err); - process.exit(1); -}); -process.on('unhandledRejection', (reason) => { - console.error(`FATAL unhandledRejection:`, reason); -}); - -app.listen(PORT, () => { - console.log(`Server läuft auf http://0.0.0.0:${PORT}`); - - // Vollständige Cache-Synchronisation beim Start (Hintergrund) - syncNormCache(); -}); - - - - +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 cors from 'cors'; +import crypto from 'node:crypto'; +import { Client, GatewayIntentBits, Partials, ChannelType, Events, type Message, VoiceState } from 'discord.js'; +import { + joinVoiceChannel, + createAudioPlayer, + createAudioResource, + AudioPlayerStatus, + NoSubscriberBehavior, + getVoiceConnection, + type VoiceConnection, + type AudioResource, + StreamType, + generateDependencyReport, + entersState, + VoiceConnectionStatus +} from '@discordjs/voice'; +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, Readable } from 'node:stream'; + +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 ?? ''; +const ADMIN_PWD = process.env.ADMIN_PWD ?? ''; +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 }); + +// Persistenter Zustand: Lautstärke/Plays + Kategorien +type Category = { id: string; name: string; color?: string; sort?: number }; + type PersistedState = { + volumes: Record; + plays: Record; + totalPlays: number; + categories?: Category[]; + fileCategories?: Record; // relPath or fileName -> categoryIds[] + fileBadges?: Record; // relPath or fileName -> custom badges (emoji/text) + selectedChannels?: Record; // guildId -> channelId (serverweite Auswahl) + entranceSounds?: Record; // userId -> relativePath or fileName + exitSounds?: Record; // userId -> relativePath or fileName +}; +// 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'); + +function readPersistedState(): PersistedState { + try { + // 1) Bevorzugt neuen Speicherort lesen + if (fs.existsSync(STATE_FILE_NEW)) { + const raw = fs.readFileSync(STATE_FILE_NEW, 'utf8'); + const parsed = JSON.parse(raw); + return { + volumes: parsed.volumes ?? {}, + plays: parsed.plays ?? {}, + totalPlays: parsed.totalPlays ?? 0, + categories: Array.isArray(parsed.categories) ? parsed.categories : [], + fileCategories: parsed.fileCategories ?? {}, + fileBadges: parsed.fileBadges ?? {}, + selectedChannels: parsed.selectedChannels ?? {}, + entranceSounds: parsed.entranceSounds ?? {}, + exitSounds: parsed.exitSounds ?? {} + } as PersistedState; + } + // 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); + const migrated: PersistedState = { + volumes: parsed.volumes ?? {}, + plays: parsed.plays ?? {}, + totalPlays: parsed.totalPlays ?? 0, + categories: Array.isArray(parsed.categories) ? parsed.categories : [], + fileCategories: parsed.fileCategories ?? {}, + fileBadges: parsed.fileBadges ?? {}, + selectedChannels: parsed.selectedChannels ?? {}, + entranceSounds: parsed.entranceSounds ?? {}, + exitSounds: parsed.exitSounds ?? {} + }; + try { + fs.mkdirSync(path.dirname(STATE_FILE_NEW), { recursive: true }); + fs.writeFileSync(STATE_FILE_NEW, JSON.stringify(migrated, null, 2), 'utf8'); + } catch {} + return migrated; + } + } catch {} + return { volumes: {}, plays: {}, totalPlays: 0 }; +} + +function writePersistedState(state: PersistedState): void { + try { + fs.mkdirSync(path.dirname(STATE_FILE_NEW), { recursive: true }); + fs.writeFileSync(STATE_FILE_NEW, JSON.stringify(state, null, 2), 'utf8'); + } catch (e) { + console.warn('Persisted state konnte nicht geschrieben werden:', e); + } +} + +const persistedState: PersistedState = readPersistedState(); + +// Debounced write: sammelt Schreibaufrufe und schreibt max. alle 2 Sekunden +let _writeTimer: ReturnType | 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; + writePersistedStateDebounced(); // Debounced: Play-Counter sind nicht kritisch + } 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'); + +// 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 }); + +// In-Memory PCM Cache: gecachte PCM-Dateien werden beim ersten Abspielen in den RAM geladen. +// Danach kein Disk-I/O mehr bei Cache-Hits -> nahezu instant playback. +const pcmMemoryCache = new Map(); +const PCM_MEMORY_CACHE_MAX_MB = Number(process.env.PCM_CACHE_MAX_MB ?? '512'); +let pcmMemoryCacheBytes = 0; + +function getPcmFromMemory(cachedPath: string): Buffer | null { + const buf = pcmMemoryCache.get(cachedPath); + if (buf) return buf; + // Erste Anfrage: von Disk in RAM laden + try { + const data = fs.readFileSync(cachedPath); + const newTotal = pcmMemoryCacheBytes + data.byteLength; + if (newTotal <= PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) { + pcmMemoryCache.set(cachedPath, data); + pcmMemoryCacheBytes = newTotal; + } + return data; + } catch { return null; } +} + +function invalidatePcmMemory(cachedPath: string): void { + const buf = pcmMemoryCache.get(cachedPath); + if (buf) { + pcmMemoryCacheBytes -= buf.byteLength; + pcmMemoryCache.delete(cachedPath); + } +} + +/** 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 {} invalidatePcmMemory(cacheFile); return null; } + } catch { return null; } + return cacheFile; +} + +/** Normalisiert eine Audiodatei via ffmpeg loudnorm und speichert das Ergebnis im Cache */ +function normalizeToCache(filePath: string): Promise { + 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 { + if (!NORMALIZE_ENABLE) return; + const t0 = Date.now(); + const allSounds = listAllSounds(); + + // Set aller erwarteten Cache-Keys + const expectedKeys = new Set(); + 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 { + 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 +void nacl.randomBytes(1); +console.log(generateDependencyReport()); + +// --- Discord Client --- +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.DirectMessages, + GatewayIntentBits.MessageContent, + ], + partials: [Partials.Channel] +}); + +type GuildAudioState = { + connection: VoiceConnection; + player: ReturnType; + guildId: string; + channelId: string; + currentResource?: AudioResource; + currentVolume: number; // 0..1 +}; +const guildAudioState = new Map(); +// Partymode: serverseitige Steuerung (global pro Guild) +const partyTimers = new Map(); +const partyActive = new Set(); +// Now-Playing: aktuell gespielter Sound pro Guild +const nowPlaying = new Map(); +// SSE-Klienten für Broadcasts (z.B. Partymode Status) +const sseClients = new Set(); +function sseBroadcast(payload: any) { + const data = `data: ${JSON.stringify(payload)}\n\n`; + for (const res of sseClients) { + try { res.write(data); } catch {} + } +} + +// 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 }); +} + +async function playFilePath(guildId: string, channelId: string, filePath: string, volume?: number, relativeKey?: string): Promise { + 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); + } + // 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 + }); + // Reuse bestehenden Player falls vorhanden + 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 {} + + // 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); + } + const useVolume = typeof volume === 'number' && Number.isFinite(volume) + ? Math.max(0, Math.min(1, volume)) + : (state.currentVolume ?? 1); + let resource: AudioResource; + if (NORMALIZE_ENABLE) { + const cachedPath = getNormCachePath(filePath); + if (cachedPath) { + // Cache-Hit: PCM aus RAM lesen (kein Disk-I/O, instant) + const pcmBuf = getPcmFromMemory(cachedPath); + if (pcmBuf) { + const useInline = useVolume !== 1; + resource = createAudioResource(Readable.from(pcmBuf), { + inlineVolume: useInline, + inputType: StreamType.Raw + }); + } else { + // Fallback: Stream von Disk + const pcmStream = fs.createReadStream(cachedPath, { highWaterMark: 256 * 1024 }); + 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)}`); + // In Memory-Cache laden fuer naechsten Aufruf + try { + const buf = fs.readFileSync(cacheFile); + if (pcmMemoryCacheBytes + buf.byteLength <= PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) { + pcmMemoryCache.set(cacheFile, buf); + pcmMemoryCacheBytes += buf.byteLength; + } + } catch {} + }); + 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 }); + } + if (resource.volume) resource.volume.setVolume(useVolume); + state.player.stop(); + 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); +} + +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' + + '?list - listet alle Audio-Dateien (mp3/wav)\n' + + '?entrance | remove - setze oder entferne deinen Entrance-Sound\n' + + '?exit | remove - setze oder entferne deinen Exit-Sound\n' + ); + return; + } + if (cmd === '?list') { + const files = fs + .readdirSync(SOUNDS_DIR) + .filter(f => { const l = f.toLowerCase(); return l.endsWith('.mp3') || l.endsWith('.wav'); }); + await reply(files.length ? files.join('\n') : 'Keine Dateien gefunden.'); + return; + } + if (cmd === '?entrance') { + 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 | 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; + } + 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; } + 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 {} + await reply(`Entrance-Sound gesetzt: ${resolve}`); return; + } + if (cmd === '?exit') { + 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 | 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; + } + 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; } + 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 {} + await reply(`Exit-Sound gesetzt: ${resolve}`); return; + } + await reply('Unbekannter Command. Nutze ?help.'); +} + +async function ensureConnectionReady(connection: VoiceConnection, channelId: string, guildId: string, guild: any): Promise { + // Versuch 1: Warten ob bestehende Connection ready wird + 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...`); + } + + // Versuch 2: Rejoin + 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.warn(`${new Date().toISOString()} | VoiceConnection still not ready after rejoin`); + } + + // Versuch 3: Komplett neu verbinden + try { connection.destroy(); } catch {} + guildAudioState.delete(guildId); + + const newConn = joinVoiceChannel({ + channelId, + guildId, + adapterCreator: guild.voiceAdapterCreator as any, + selfMute: false, + selfDeaf: false + }); + try { + await entersState(newConn, VoiceConnectionStatus.Ready, 15_000); + console.log(`${new Date().toISOString()} | VoiceConnection ready after fresh join`); + return newConn; + } catch (e3) { + console.error(`${new Date().toISOString()} | VoiceConnection failed after all attempts, cleaning up`); + try { newConn.destroy(); } catch {} + guildAudioState.delete(guildId); + throw new Error('Voice connection failed after 3 attempts'); + } +} + +function attachVoiceLifecycle(state: GuildAudioState, guild: any) { + const { connection } = state; + // Mehrfach-Registrierung verhindern + if ((connection as any).__lifecycleAttached) return; + try { (connection as any).setMaxListeners?.(0); } catch {} + + // Retry-Tracking um Endlosschleife zu verhindern + let reconnectAttempts = 0; + const MAX_RECONNECT_ATTEMPTS = 3; + let isReconnecting = false; + + connection.on('stateChange', async (oldS: any, newS: any) => { + console.log(`${new Date().toISOString()} | VoiceConnection: ${oldS.status} -> ${newS.status}`); + + // Ready zurückgesetzt -> Retry-Counter reset + if (newS.status === VoiceConnectionStatus.Ready) { + reconnectAttempts = 0; + isReconnecting = false; + return; + } + + // Re-Entranz verhindern: wenn schon ein Reconnect läuft, nicht nochmal starten + if (isReconnecting) return; + + 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 { + if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + reconnectAttempts++; + console.warn(`${new Date().toISOString()} | Disconnected rejoin attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`); + connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); + } else { + console.error(`${new Date().toISOString()} | Max reconnect attempts reached from Disconnected, destroying and rejoining fresh`); + reconnectAttempts = 0; + try { connection.destroy(); } catch {} + 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.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) { + isReconnecting = true; + try { + await entersState(connection, VoiceConnectionStatus.Ready, 15_000); + // Ready wird oben im Handler behandelt + } catch (e) { + reconnectAttempts++; + if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + const backoffMs = reconnectAttempts * 2_000; + console.warn(`${new Date().toISOString()} | Voice not ready from ${newS.status}, attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}, backoff ${backoffMs}ms`); + await new Promise(r => setTimeout(r, backoffMs)); + isReconnecting = false; + connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); + } else { + console.error(`${new Date().toISOString()} | Max reconnect attempts reached, destroying and rejoining fresh`); + reconnectAttempts = 0; + isReconnecting = false; + try { connection.destroy(); } catch {} + 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); + } + } + } + } catch (e) { + isReconnecting = false; + console.error(`${new Date().toISOString()} | Voice lifecycle handler error`, e); + } + }); + (connection as any).__lifecycleAttached = true; +} + +client.once(Events.ClientReady, () => { + console.log(`Bot eingeloggt als ${client.user?.tag}`); +}); + +// Voice State Updates: Entrance/Exit +client.on(Events.VoiceStateUpdate, async (oldState: VoiceState, newState: VoiceState) => { + try { + const userId = (newState.id || oldState.id) as string; + if (!userId) return; + // Eigene Events ignorieren + if (userId === client.user?.id) return; + const guildId = (newState.guild?.id || oldState.guild?.id) as string; + if (!guildId) return; + + const before = oldState.channelId; + const after = newState.channelId; + console.log(`${new Date().toISOString()} | VoiceStateUpdate user=${userId} before=${before ?? '-'} after=${after ?? '-'}`); + + // 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}`); + 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)) { + try { + // Dem Channel beitreten und Sound spielen + await playFilePath(guildId, after, abs, undefined, rel); + console.log(`${new Date().toISOString()} | Entrance played for ${userId}: ${rel}`); + } catch (e) { console.warn('Entrance play error', e); } + } + } + } + // 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}`); + 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)) { + try { + await playFilePath(guildId, before, abs, undefined, rel); + console.log(`${new Date().toISOString()} | Exit played for ${userId}: ${rel}`); + } catch (e) { console.warn('Exit play error', e); } + } + } + } 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}`); + } + } catch (e) { + console.warn('VoiceStateUpdate entrance/exit handling error', e); + } +}); + +client.on(Events.MessageCreate, async (message: Message) => { + try { + if (message.author?.bot) return; + // Commands überall annehmen (inkl. DMs) + const content = (message.content || '').trim(); + if (content.startsWith('?')) { + await handleCommand(message, content); + return; + } + // Dateiuploads nur per DM + if (!message.channel?.isDMBased?.()) return; + if (message.attachments.size === 0) return; + + for (const [, attachment] of message.attachments) { + const name = attachment.name ?? 'upload'; + const lower = name.toLowerCase(); + if (!(lower.endsWith('.mp3') || lower.endsWith('.wav'))) continue; + + 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; + const ext = path.parse(safeName).ext || (lower.endsWith('.wav') ? '.wav' : '.mp3'); + 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)); + // 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) { + 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) => { + 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(); + 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 { + 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')) }); +}); + +app.get('/api/sounds', (req: Request, res: Response) => { + const q = String(req.query.q ?? '').toLowerCase(); + const folderFilter = typeof req.query.folder === 'string' ? (req.query.folder as string) : '__all__'; + const categoryFilter = typeof (req.query as any).categoryId === 'string' ? String((req.query as any).categoryId) : undefined; + const fuzzyParam = String((req.query as any).fuzzy ?? '0'); + const useFuzzy = fuzzyParam === '1' || fuzzyParam === 'true'; + + const allItems = listAllSounds(); + + // Ordner-Statistik aus allItems ableiten + const folderCounts = new Map(); + 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 }); + } + + // 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)); + let itemsByFolder = allItems; + if (folderFilter !== '__all__') { + 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)); + } + } + // 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) { + 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)); + } + } + + const total = allItems.length; + const recentCount = Math.min(10, total); + // 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 }>; + + const foldersOut = [ + { key: '__all__', name: 'Alle', count: total }, + { key: '__recent__', name: 'Neu', count: recentCount }, + ...(top3.length ? [{ key: '__top3__', name: 'Most Played (3)', count: top3.length }] : []), + ...folders + ]; + // isRecent-Flag für UI (Top 5 der neuesten) + // Kategorie-Filter (virtuell) anwenden, wenn gesetzt + let result = filteredItems; + if (categoryFilter) { + const fc = persistedState.fileCategories ?? {}; + result = result.filter((it) => { + const key = it.relativePath ?? it.fileName; + const cats = fc[key] ?? []; + return cats.includes(categoryFilter); + }); + } + if (folderFilter === '__top3__') { + const keys = new Set(top3.map(t => t.key.split(':')[1])); + result = allItems.filter(i => keys.has(i.relativePath ?? i.fileName)); + } + + // 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; + }); + + res.json({ items: withRecentFlag, total, folders: foldersOut, categories: persistedState.categories ?? [], fileCategories: persistedState.fileCategories ?? {} }); +}); + +// --- 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) { + 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' }); + } + } 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' }); + 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 = 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 ?? [] }); +}); + +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 }); +}); + +// 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 }); +}); + +// 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 }); +}); + +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); + const result: Array<{ guildId: string; guildName: string; channelId: string; channelName: string; selected?: boolean }> = []; + 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) { + const sel = getSelectedChannelForGuild(guild.id); + result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name, selected: sel === ch.id }); + } + } + } + result.sort((a, b) => a.guildName.localeCompare(b.guildName) || a.channelName.localeCompare(b.channelName)); + res.json(result); +}); + +// 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' }); + } +}); + +app.post('/api/play', async (req: Request, res: Response) => { + try { + const { soundName, guildId, channelId, volume, folder, relativePath } = req.body as { + soundName?: string; + guildId?: string; + channelId?: string; + volume?: number; // 0..1 + folder?: string; // optional subfolder key + relativePath?: string; // optional direct relative path + }; + if (!soundName || !guildId || !channelId) return res.status(400).json({ error: 'soundName, guildId, channelId erforderlich' }); + + let filePath: string; + if (relativePath) filePath = path.join(SOUNDS_DIR, relativePath); + 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; + } + if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Sound nicht gefunden' }); + + // 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); + return res.status(500).json({ error: err?.message ?? 'Unbekannter Fehler' }); + } +}); + +// 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) { + // Kein aktiver Player: nur persistieren für nächste Wiedergabe + persistedState.volumes[guildId] = safeVolume; + writePersistedState(persistedState); + // Broadcast neue Lautstärke an alle Clients + sseBroadcast({ type: 'volume', guildId, volume: safeVolume }); + return res.json({ ok: true, volume: safeVolume, persistedOnly: true }); + } + state.currentVolume = safeVolume; + if (state.currentResource?.volume) { + state.currentResource.volume.setVolume(safeVolume); + console.log(`${new Date().toISOString()} | live setVolume(${safeVolume}) guild=${guildId}`); + } + persistedState.volumes[guildId] = safeVolume; + writePersistedState(persistedState); + // Broadcast neue Lautstärke an alle Clients + sseBroadcast({ type: 'volume', guildId, volume: safeVolume }); + 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' }); + } +}); + +// 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 }); +}); + +// 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); + // 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); + if (t) clearTimeout(t); + partyTimers.delete(guildId); + partyActive.delete(guildId); + sseBroadcast({ type: 'party', guildId, active: false }); + } 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(); + // Broadcast Status + sseBroadcast({ type: 'party', guildId, active: true, channelId }); +} + +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); + sseBroadcast({ type: 'party', guildId: id, active: false }); + return res.json({ ok: true }); + } catch (e: any) { + console.error('party/stop error', e); + return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' }); + } +}); + +// 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 + try { + 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 + const ping = setInterval(() => { try { res.write(':\n\n'); } catch {} }, 15_000); + + sseClients.add(res); + req.on('close', () => { + sseClients.delete(res); + clearInterval(ping); + try { res.end(); } catch {} + }); +}); + +// --- 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)) { + app.use(express.static(webDistPath)); + app.get('/{*splat}', (_req, res) => { + res.sendFile(path.join(webDistPath, 'index.html')); + }); +} + +// Node 24 warnt bei negativen Timeout-Werten (aus @discordjs/voice intern) - harmlos unterdruecken +process.on('warning', (warning) => { + if (warning.name === 'TimeoutNegativeWarning') return; + console.warn(warning.name + ': ' + warning.message); +}); +process.on('uncaughtException', (err) => { + console.error(`FATAL uncaughtException:`, err); + process.exit(1); +}); +process.on('unhandledRejection', (reason) => { + console.error(`FATAL unhandledRejection:`, reason); +}); + +app.listen(PORT, () => { + console.log(`Server läuft auf http://0.0.0.0:${PORT}`); + + // Vollständige Cache-Synchronisation beim Start (Hintergrund) + syncNormCache(); +}); + + + + From 761032a28043622299b29a93afa8935bbada329a Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 5 Mar 2026 16:04:15 +0100 Subject: [PATCH 18/35] =?UTF-8?q?v2.0.0=20=F0=9F=9A=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Highlights: - Node 24 LTS Runtime (npm 11) - Static ffmpeg Binary (Image -45%, 892MB -> 493MB) - In-Memory PCM Cache (instant Sound-Playback nach erstem Abspielen) - InlineVolume Bypass bei Volume 1.0 - sodium-native v5, Express v5, @discordjs/voice 0.19 (DAVE E2EE) - Alle Dependencies auf aktuellem Stand --- Dockerfile | 2 +- server/package.json | 2 +- web/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index c4bc476..2883406 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ COPY web/package*.json ./ RUN npm install --no-audit --no-fund COPY web/ . ARG VITE_BUILD_CHANNEL=stable -ARG VITE_APP_VERSION=1.1.0 +ARG VITE_APP_VERSION=2.0.0 ENV VITE_BUILD_CHANNEL=$VITE_BUILD_CHANNEL ENV VITE_APP_VERSION=$VITE_APP_VERSION RUN npm run build diff --git a/server/package.json b/server/package.json index f6e00d8..b932289 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "discord-soundboard-server", - "version": "1.1.1", + "version": "2.0.0", "private": true, "type": "module", "main": "dist/index.js", diff --git a/web/package.json b/web/package.json index 14fba7c..175d661 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "discord-soundboard-web", "private": true, - "version": "1.1.1", + "version": "2.0.0", "type": "module", "scripts": { "dev": "vite", From 1a1fdf69c85fb4350339b18378fd08b53932090c Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 5 Mar 2026 16:28:35 +0100 Subject: [PATCH 19/35] feat: Verbindungsdetails Modal mit Live-Ping - Backend: Voice-Stats (Ping, Gateway, Status, Uptime) via SSE alle 5s - Frontend: Klick auf Verbunden oeffnet Modal mit allen Verbindungsdetails - Ping-Anzeige direkt im Header neben Verbunden - Farbcodierte Ping-Dots (gruen <80ms, gelb <150ms, rot >=150ms) - Uptime-Zaehler seit letztem VoiceConnection Ready - connectedSince Tracking pro Guild --- server/src/index.ts | 3246 ++++++++++++++++++++++--------------------- web/src/App.tsx | 2424 ++++++++++++++++---------------- web/src/styles.css | 84 ++ 3 files changed, 2980 insertions(+), 2774 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 3c536c4..01c7060 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,1607 +1,1639 @@ -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 cors from 'cors'; -import crypto from 'node:crypto'; -import { Client, GatewayIntentBits, Partials, ChannelType, Events, type Message, VoiceState } from 'discord.js'; -import { - joinVoiceChannel, - createAudioPlayer, - createAudioResource, - AudioPlayerStatus, - NoSubscriberBehavior, - getVoiceConnection, - type VoiceConnection, - type AudioResource, - StreamType, - generateDependencyReport, - entersState, - VoiceConnectionStatus -} from '@discordjs/voice'; -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, Readable } from 'node:stream'; - -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 ?? ''; -const ADMIN_PWD = process.env.ADMIN_PWD ?? ''; -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 }); - -// Persistenter Zustand: Lautstärke/Plays + Kategorien -type Category = { id: string; name: string; color?: string; sort?: number }; - type PersistedState = { - volumes: Record; - plays: Record; - totalPlays: number; - categories?: Category[]; - fileCategories?: Record; // relPath or fileName -> categoryIds[] - fileBadges?: Record; // relPath or fileName -> custom badges (emoji/text) - selectedChannels?: Record; // guildId -> channelId (serverweite Auswahl) - entranceSounds?: Record; // userId -> relativePath or fileName - exitSounds?: Record; // userId -> relativePath or fileName -}; -// 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'); - -function readPersistedState(): PersistedState { - try { - // 1) Bevorzugt neuen Speicherort lesen - if (fs.existsSync(STATE_FILE_NEW)) { - const raw = fs.readFileSync(STATE_FILE_NEW, 'utf8'); - const parsed = JSON.parse(raw); - return { - volumes: parsed.volumes ?? {}, - plays: parsed.plays ?? {}, - totalPlays: parsed.totalPlays ?? 0, - categories: Array.isArray(parsed.categories) ? parsed.categories : [], - fileCategories: parsed.fileCategories ?? {}, - fileBadges: parsed.fileBadges ?? {}, - selectedChannels: parsed.selectedChannels ?? {}, - entranceSounds: parsed.entranceSounds ?? {}, - exitSounds: parsed.exitSounds ?? {} - } as PersistedState; - } - // 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); - const migrated: PersistedState = { - volumes: parsed.volumes ?? {}, - plays: parsed.plays ?? {}, - totalPlays: parsed.totalPlays ?? 0, - categories: Array.isArray(parsed.categories) ? parsed.categories : [], - fileCategories: parsed.fileCategories ?? {}, - fileBadges: parsed.fileBadges ?? {}, - selectedChannels: parsed.selectedChannels ?? {}, - entranceSounds: parsed.entranceSounds ?? {}, - exitSounds: parsed.exitSounds ?? {} - }; - try { - fs.mkdirSync(path.dirname(STATE_FILE_NEW), { recursive: true }); - fs.writeFileSync(STATE_FILE_NEW, JSON.stringify(migrated, null, 2), 'utf8'); - } catch {} - return migrated; - } - } catch {} - return { volumes: {}, plays: {}, totalPlays: 0 }; -} - -function writePersistedState(state: PersistedState): void { - try { - fs.mkdirSync(path.dirname(STATE_FILE_NEW), { recursive: true }); - fs.writeFileSync(STATE_FILE_NEW, JSON.stringify(state, null, 2), 'utf8'); - } catch (e) { - console.warn('Persisted state konnte nicht geschrieben werden:', e); - } -} - -const persistedState: PersistedState = readPersistedState(); - -// Debounced write: sammelt Schreibaufrufe und schreibt max. alle 2 Sekunden -let _writeTimer: ReturnType | 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; - writePersistedStateDebounced(); // Debounced: Play-Counter sind nicht kritisch - } 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'); - -// 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 }); - -// In-Memory PCM Cache: gecachte PCM-Dateien werden beim ersten Abspielen in den RAM geladen. -// Danach kein Disk-I/O mehr bei Cache-Hits -> nahezu instant playback. -const pcmMemoryCache = new Map(); -const PCM_MEMORY_CACHE_MAX_MB = Number(process.env.PCM_CACHE_MAX_MB ?? '512'); -let pcmMemoryCacheBytes = 0; - -function getPcmFromMemory(cachedPath: string): Buffer | null { - const buf = pcmMemoryCache.get(cachedPath); - if (buf) return buf; - // Erste Anfrage: von Disk in RAM laden - try { - const data = fs.readFileSync(cachedPath); - const newTotal = pcmMemoryCacheBytes + data.byteLength; - if (newTotal <= PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) { - pcmMemoryCache.set(cachedPath, data); - pcmMemoryCacheBytes = newTotal; - } - return data; - } catch { return null; } -} - -function invalidatePcmMemory(cachedPath: string): void { - const buf = pcmMemoryCache.get(cachedPath); - if (buf) { - pcmMemoryCacheBytes -= buf.byteLength; - pcmMemoryCache.delete(cachedPath); - } -} - -/** 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 {} invalidatePcmMemory(cacheFile); return null; } - } catch { return null; } - return cacheFile; -} - -/** Normalisiert eine Audiodatei via ffmpeg loudnorm und speichert das Ergebnis im Cache */ -function normalizeToCache(filePath: string): Promise { - 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 { - if (!NORMALIZE_ENABLE) return; - const t0 = Date.now(); - const allSounds = listAllSounds(); - - // Set aller erwarteten Cache-Keys - const expectedKeys = new Set(); - 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 { - 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 -void nacl.randomBytes(1); -console.log(generateDependencyReport()); - -// --- Discord Client --- -const client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildVoiceStates, - GatewayIntentBits.DirectMessages, - GatewayIntentBits.MessageContent, - ], - partials: [Partials.Channel] -}); - -type GuildAudioState = { - connection: VoiceConnection; - player: ReturnType; - guildId: string; - channelId: string; - currentResource?: AudioResource; - currentVolume: number; // 0..1 -}; -const guildAudioState = new Map(); -// Partymode: serverseitige Steuerung (global pro Guild) -const partyTimers = new Map(); -const partyActive = new Set(); -// Now-Playing: aktuell gespielter Sound pro Guild -const nowPlaying = new Map(); -// SSE-Klienten für Broadcasts (z.B. Partymode Status) -const sseClients = new Set(); -function sseBroadcast(payload: any) { - const data = `data: ${JSON.stringify(payload)}\n\n`; - for (const res of sseClients) { - try { res.write(data); } catch {} - } -} - -// 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 }); -} - -async function playFilePath(guildId: string, channelId: string, filePath: string, volume?: number, relativeKey?: string): Promise { - 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); - } - // 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 - }); - // Reuse bestehenden Player falls vorhanden - 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 {} - - // 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); - } - const useVolume = typeof volume === 'number' && Number.isFinite(volume) - ? Math.max(0, Math.min(1, volume)) - : (state.currentVolume ?? 1); - let resource: AudioResource; - if (NORMALIZE_ENABLE) { - const cachedPath = getNormCachePath(filePath); - if (cachedPath) { - // Cache-Hit: PCM aus RAM lesen (kein Disk-I/O, instant) - const pcmBuf = getPcmFromMemory(cachedPath); - if (pcmBuf) { - const useInline = useVolume !== 1; - resource = createAudioResource(Readable.from(pcmBuf), { - inlineVolume: useInline, - inputType: StreamType.Raw - }); - } else { - // Fallback: Stream von Disk - const pcmStream = fs.createReadStream(cachedPath, { highWaterMark: 256 * 1024 }); - 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)}`); - // In Memory-Cache laden fuer naechsten Aufruf - try { - const buf = fs.readFileSync(cacheFile); - if (pcmMemoryCacheBytes + buf.byteLength <= PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) { - pcmMemoryCache.set(cacheFile, buf); - pcmMemoryCacheBytes += buf.byteLength; - } - } catch {} - }); - 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 }); - } - if (resource.volume) resource.volume.setVolume(useVolume); - state.player.stop(); - 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); -} - -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' + - '?list - listet alle Audio-Dateien (mp3/wav)\n' + - '?entrance | remove - setze oder entferne deinen Entrance-Sound\n' + - '?exit | remove - setze oder entferne deinen Exit-Sound\n' - ); - return; - } - if (cmd === '?list') { - const files = fs - .readdirSync(SOUNDS_DIR) - .filter(f => { const l = f.toLowerCase(); return l.endsWith('.mp3') || l.endsWith('.wav'); }); - await reply(files.length ? files.join('\n') : 'Keine Dateien gefunden.'); - return; - } - if (cmd === '?entrance') { - 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 | 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; - } - 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; } - 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 {} - await reply(`Entrance-Sound gesetzt: ${resolve}`); return; - } - if (cmd === '?exit') { - 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 | 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; - } - 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; } - 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 {} - await reply(`Exit-Sound gesetzt: ${resolve}`); return; - } - await reply('Unbekannter Command. Nutze ?help.'); -} - -async function ensureConnectionReady(connection: VoiceConnection, channelId: string, guildId: string, guild: any): Promise { - // Versuch 1: Warten ob bestehende Connection ready wird - 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...`); - } - - // Versuch 2: Rejoin - 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.warn(`${new Date().toISOString()} | VoiceConnection still not ready after rejoin`); - } - - // Versuch 3: Komplett neu verbinden - try { connection.destroy(); } catch {} - guildAudioState.delete(guildId); - - const newConn = joinVoiceChannel({ - channelId, - guildId, - adapterCreator: guild.voiceAdapterCreator as any, - selfMute: false, - selfDeaf: false - }); - try { - await entersState(newConn, VoiceConnectionStatus.Ready, 15_000); - console.log(`${new Date().toISOString()} | VoiceConnection ready after fresh join`); - return newConn; - } catch (e3) { - console.error(`${new Date().toISOString()} | VoiceConnection failed after all attempts, cleaning up`); - try { newConn.destroy(); } catch {} - guildAudioState.delete(guildId); - throw new Error('Voice connection failed after 3 attempts'); - } -} - -function attachVoiceLifecycle(state: GuildAudioState, guild: any) { - const { connection } = state; - // Mehrfach-Registrierung verhindern - if ((connection as any).__lifecycleAttached) return; - try { (connection as any).setMaxListeners?.(0); } catch {} - - // Retry-Tracking um Endlosschleife zu verhindern - let reconnectAttempts = 0; - const MAX_RECONNECT_ATTEMPTS = 3; - let isReconnecting = false; - - connection.on('stateChange', async (oldS: any, newS: any) => { - console.log(`${new Date().toISOString()} | VoiceConnection: ${oldS.status} -> ${newS.status}`); - - // Ready zurückgesetzt -> Retry-Counter reset - if (newS.status === VoiceConnectionStatus.Ready) { - reconnectAttempts = 0; - isReconnecting = false; - return; - } - - // Re-Entranz verhindern: wenn schon ein Reconnect läuft, nicht nochmal starten - if (isReconnecting) return; - - 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 { - if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { - reconnectAttempts++; - console.warn(`${new Date().toISOString()} | Disconnected rejoin attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`); - connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); - } else { - console.error(`${new Date().toISOString()} | Max reconnect attempts reached from Disconnected, destroying and rejoining fresh`); - reconnectAttempts = 0; - try { connection.destroy(); } catch {} - 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.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) { - isReconnecting = true; - try { - await entersState(connection, VoiceConnectionStatus.Ready, 15_000); - // Ready wird oben im Handler behandelt - } catch (e) { - reconnectAttempts++; - if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { - const backoffMs = reconnectAttempts * 2_000; - console.warn(`${new Date().toISOString()} | Voice not ready from ${newS.status}, attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}, backoff ${backoffMs}ms`); - await new Promise(r => setTimeout(r, backoffMs)); - isReconnecting = false; - connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); - } else { - console.error(`${new Date().toISOString()} | Max reconnect attempts reached, destroying and rejoining fresh`); - reconnectAttempts = 0; - isReconnecting = false; - try { connection.destroy(); } catch {} - 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); - } - } - } - } catch (e) { - isReconnecting = false; - console.error(`${new Date().toISOString()} | Voice lifecycle handler error`, e); - } - }); - (connection as any).__lifecycleAttached = true; -} - -client.once(Events.ClientReady, () => { - console.log(`Bot eingeloggt als ${client.user?.tag}`); -}); - -// Voice State Updates: Entrance/Exit -client.on(Events.VoiceStateUpdate, async (oldState: VoiceState, newState: VoiceState) => { - try { - const userId = (newState.id || oldState.id) as string; - if (!userId) return; - // Eigene Events ignorieren - if (userId === client.user?.id) return; - const guildId = (newState.guild?.id || oldState.guild?.id) as string; - if (!guildId) return; - - const before = oldState.channelId; - const after = newState.channelId; - console.log(`${new Date().toISOString()} | VoiceStateUpdate user=${userId} before=${before ?? '-'} after=${after ?? '-'}`); - - // 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}`); - 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)) { - try { - // Dem Channel beitreten und Sound spielen - await playFilePath(guildId, after, abs, undefined, rel); - console.log(`${new Date().toISOString()} | Entrance played for ${userId}: ${rel}`); - } catch (e) { console.warn('Entrance play error', e); } - } - } - } - // 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}`); - 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)) { - try { - await playFilePath(guildId, before, abs, undefined, rel); - console.log(`${new Date().toISOString()} | Exit played for ${userId}: ${rel}`); - } catch (e) { console.warn('Exit play error', e); } - } - } - } 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}`); - } - } catch (e) { - console.warn('VoiceStateUpdate entrance/exit handling error', e); - } -}); - -client.on(Events.MessageCreate, async (message: Message) => { - try { - if (message.author?.bot) return; - // Commands überall annehmen (inkl. DMs) - const content = (message.content || '').trim(); - if (content.startsWith('?')) { - await handleCommand(message, content); - return; - } - // Dateiuploads nur per DM - if (!message.channel?.isDMBased?.()) return; - if (message.attachments.size === 0) return; - - for (const [, attachment] of message.attachments) { - const name = attachment.name ?? 'upload'; - const lower = name.toLowerCase(); - if (!(lower.endsWith('.mp3') || lower.endsWith('.wav'))) continue; - - 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; - const ext = path.parse(safeName).ext || (lower.endsWith('.wav') ? '.wav' : '.mp3'); - 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)); - // 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) { - 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) => { - 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(); - 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 { - 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')) }); -}); - -app.get('/api/sounds', (req: Request, res: Response) => { - const q = String(req.query.q ?? '').toLowerCase(); - const folderFilter = typeof req.query.folder === 'string' ? (req.query.folder as string) : '__all__'; - const categoryFilter = typeof (req.query as any).categoryId === 'string' ? String((req.query as any).categoryId) : undefined; - const fuzzyParam = String((req.query as any).fuzzy ?? '0'); - const useFuzzy = fuzzyParam === '1' || fuzzyParam === 'true'; - - const allItems = listAllSounds(); - - // Ordner-Statistik aus allItems ableiten - const folderCounts = new Map(); - 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 }); - } - - // 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)); - let itemsByFolder = allItems; - if (folderFilter !== '__all__') { - 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)); - } - } - // 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) { - 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)); - } - } - - const total = allItems.length; - const recentCount = Math.min(10, total); - // 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 }>; - - const foldersOut = [ - { key: '__all__', name: 'Alle', count: total }, - { key: '__recent__', name: 'Neu', count: recentCount }, - ...(top3.length ? [{ key: '__top3__', name: 'Most Played (3)', count: top3.length }] : []), - ...folders - ]; - // isRecent-Flag für UI (Top 5 der neuesten) - // Kategorie-Filter (virtuell) anwenden, wenn gesetzt - let result = filteredItems; - if (categoryFilter) { - const fc = persistedState.fileCategories ?? {}; - result = result.filter((it) => { - const key = it.relativePath ?? it.fileName; - const cats = fc[key] ?? []; - return cats.includes(categoryFilter); - }); - } - if (folderFilter === '__top3__') { - const keys = new Set(top3.map(t => t.key.split(':')[1])); - result = allItems.filter(i => keys.has(i.relativePath ?? i.fileName)); - } - - // 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; - }); - - res.json({ items: withRecentFlag, total, folders: foldersOut, categories: persistedState.categories ?? [], fileCategories: persistedState.fileCategories ?? {} }); -}); - -// --- 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) { - 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' }); - } - } 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' }); - 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 = 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 ?? [] }); -}); - -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 }); -}); - -// 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 }); -}); - -// 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 }); -}); - -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); - const result: Array<{ guildId: string; guildName: string; channelId: string; channelName: string; selected?: boolean }> = []; - 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) { - const sel = getSelectedChannelForGuild(guild.id); - result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name, selected: sel === ch.id }); - } - } - } - result.sort((a, b) => a.guildName.localeCompare(b.guildName) || a.channelName.localeCompare(b.channelName)); - res.json(result); -}); - -// 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' }); - } -}); - -app.post('/api/play', async (req: Request, res: Response) => { - try { - const { soundName, guildId, channelId, volume, folder, relativePath } = req.body as { - soundName?: string; - guildId?: string; - channelId?: string; - volume?: number; // 0..1 - folder?: string; // optional subfolder key - relativePath?: string; // optional direct relative path - }; - if (!soundName || !guildId || !channelId) return res.status(400).json({ error: 'soundName, guildId, channelId erforderlich' }); - - let filePath: string; - if (relativePath) filePath = path.join(SOUNDS_DIR, relativePath); - 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; - } - if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Sound nicht gefunden' }); - - // 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); - return res.status(500).json({ error: err?.message ?? 'Unbekannter Fehler' }); - } -}); - -// 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) { - // Kein aktiver Player: nur persistieren für nächste Wiedergabe - persistedState.volumes[guildId] = safeVolume; - writePersistedState(persistedState); - // Broadcast neue Lautstärke an alle Clients - sseBroadcast({ type: 'volume', guildId, volume: safeVolume }); - return res.json({ ok: true, volume: safeVolume, persistedOnly: true }); - } - state.currentVolume = safeVolume; - if (state.currentResource?.volume) { - state.currentResource.volume.setVolume(safeVolume); - console.log(`${new Date().toISOString()} | live setVolume(${safeVolume}) guild=${guildId}`); - } - persistedState.volumes[guildId] = safeVolume; - writePersistedState(persistedState); - // Broadcast neue Lautstärke an alle Clients - sseBroadcast({ type: 'volume', guildId, volume: safeVolume }); - 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' }); - } -}); - -// 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 }); -}); - -// 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); - // 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); - if (t) clearTimeout(t); - partyTimers.delete(guildId); - partyActive.delete(guildId); - sseBroadcast({ type: 'party', guildId, active: false }); - } 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(); - // Broadcast Status - sseBroadcast({ type: 'party', guildId, active: true, channelId }); -} - -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); - sseBroadcast({ type: 'party', guildId: id, active: false }); - return res.json({ ok: true }); - } catch (e: any) { - console.error('party/stop error', e); - return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' }); - } -}); - -// 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 - try { - 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 - const ping = setInterval(() => { try { res.write(':\n\n'); } catch {} }, 15_000); - - sseClients.add(res); - req.on('close', () => { - sseClients.delete(res); - clearInterval(ping); - try { res.end(); } catch {} - }); -}); - -// --- 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)) { - app.use(express.static(webDistPath)); - app.get('/{*splat}', (_req, res) => { - res.sendFile(path.join(webDistPath, 'index.html')); - }); -} - -// Node 24 warnt bei negativen Timeout-Werten (aus @discordjs/voice intern) - harmlos unterdruecken -process.on('warning', (warning) => { - if (warning.name === 'TimeoutNegativeWarning') return; - console.warn(warning.name + ': ' + warning.message); -}); -process.on('uncaughtException', (err) => { - console.error(`FATAL uncaughtException:`, err); - process.exit(1); -}); -process.on('unhandledRejection', (reason) => { - console.error(`FATAL unhandledRejection:`, reason); -}); - -app.listen(PORT, () => { - console.log(`Server läuft auf http://0.0.0.0:${PORT}`); - - // Vollständige Cache-Synchronisation beim Start (Hintergrund) - syncNormCache(); -}); - - - - +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 cors from 'cors'; +import crypto from 'node:crypto'; +import { Client, GatewayIntentBits, Partials, ChannelType, Events, type Message, VoiceState } from 'discord.js'; +import { + joinVoiceChannel, + createAudioPlayer, + createAudioResource, + AudioPlayerStatus, + NoSubscriberBehavior, + getVoiceConnection, + type VoiceConnection, + type AudioResource, + StreamType, + generateDependencyReport, + entersState, + VoiceConnectionStatus +} from '@discordjs/voice'; +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, Readable } from 'node:stream'; + +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 ?? ''; +const ADMIN_PWD = process.env.ADMIN_PWD ?? ''; +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 }); + +// Persistenter Zustand: Lautstärke/Plays + Kategorien +type Category = { id: string; name: string; color?: string; sort?: number }; + type PersistedState = { + volumes: Record; + plays: Record; + totalPlays: number; + categories?: Category[]; + fileCategories?: Record; // relPath or fileName -> categoryIds[] + fileBadges?: Record; // relPath or fileName -> custom badges (emoji/text) + selectedChannels?: Record; // guildId -> channelId (serverweite Auswahl) + entranceSounds?: Record; // userId -> relativePath or fileName + exitSounds?: Record; // userId -> relativePath or fileName +}; +// 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'); + +function readPersistedState(): PersistedState { + try { + // 1) Bevorzugt neuen Speicherort lesen + if (fs.existsSync(STATE_FILE_NEW)) { + const raw = fs.readFileSync(STATE_FILE_NEW, 'utf8'); + const parsed = JSON.parse(raw); + return { + volumes: parsed.volumes ?? {}, + plays: parsed.plays ?? {}, + totalPlays: parsed.totalPlays ?? 0, + categories: Array.isArray(parsed.categories) ? parsed.categories : [], + fileCategories: parsed.fileCategories ?? {}, + fileBadges: parsed.fileBadges ?? {}, + selectedChannels: parsed.selectedChannels ?? {}, + entranceSounds: parsed.entranceSounds ?? {}, + exitSounds: parsed.exitSounds ?? {} + } as PersistedState; + } + // 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); + const migrated: PersistedState = { + volumes: parsed.volumes ?? {}, + plays: parsed.plays ?? {}, + totalPlays: parsed.totalPlays ?? 0, + categories: Array.isArray(parsed.categories) ? parsed.categories : [], + fileCategories: parsed.fileCategories ?? {}, + fileBadges: parsed.fileBadges ?? {}, + selectedChannels: parsed.selectedChannels ?? {}, + entranceSounds: parsed.entranceSounds ?? {}, + exitSounds: parsed.exitSounds ?? {} + }; + try { + fs.mkdirSync(path.dirname(STATE_FILE_NEW), { recursive: true }); + fs.writeFileSync(STATE_FILE_NEW, JSON.stringify(migrated, null, 2), 'utf8'); + } catch {} + return migrated; + } + } catch {} + return { volumes: {}, plays: {}, totalPlays: 0 }; +} + +function writePersistedState(state: PersistedState): void { + try { + fs.mkdirSync(path.dirname(STATE_FILE_NEW), { recursive: true }); + fs.writeFileSync(STATE_FILE_NEW, JSON.stringify(state, null, 2), 'utf8'); + } catch (e) { + console.warn('Persisted state konnte nicht geschrieben werden:', e); + } +} + +const persistedState: PersistedState = readPersistedState(); + +// Debounced write: sammelt Schreibaufrufe und schreibt max. alle 2 Sekunden +let _writeTimer: ReturnType | 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; + writePersistedStateDebounced(); // Debounced: Play-Counter sind nicht kritisch + } 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'); + +// 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 }); + +// In-Memory PCM Cache: gecachte PCM-Dateien werden beim ersten Abspielen in den RAM geladen. +// Danach kein Disk-I/O mehr bei Cache-Hits -> nahezu instant playback. +const pcmMemoryCache = new Map(); +const PCM_MEMORY_CACHE_MAX_MB = Number(process.env.PCM_CACHE_MAX_MB ?? '512'); +let pcmMemoryCacheBytes = 0; + +function getPcmFromMemory(cachedPath: string): Buffer | null { + const buf = pcmMemoryCache.get(cachedPath); + if (buf) return buf; + // Erste Anfrage: von Disk in RAM laden + try { + const data = fs.readFileSync(cachedPath); + const newTotal = pcmMemoryCacheBytes + data.byteLength; + if (newTotal <= PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) { + pcmMemoryCache.set(cachedPath, data); + pcmMemoryCacheBytes = newTotal; + } + return data; + } catch { return null; } +} + +function invalidatePcmMemory(cachedPath: string): void { + const buf = pcmMemoryCache.get(cachedPath); + if (buf) { + pcmMemoryCacheBytes -= buf.byteLength; + pcmMemoryCache.delete(cachedPath); + } +} + +/** 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 {} invalidatePcmMemory(cacheFile); return null; } + } catch { return null; } + return cacheFile; +} + +/** Normalisiert eine Audiodatei via ffmpeg loudnorm und speichert das Ergebnis im Cache */ +function normalizeToCache(filePath: string): Promise { + 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 { + if (!NORMALIZE_ENABLE) return; + const t0 = Date.now(); + const allSounds = listAllSounds(); + + // Set aller erwarteten Cache-Keys + const expectedKeys = new Set(); + 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 { + 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 +void nacl.randomBytes(1); +console.log(generateDependencyReport()); + +// --- Discord Client --- +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.DirectMessages, + GatewayIntentBits.MessageContent, + ], + partials: [Partials.Channel] +}); + +type GuildAudioState = { + connection: VoiceConnection; + player: ReturnType; + guildId: string; + channelId: string; + currentResource?: AudioResource; + currentVolume: number; // 0..1 +}; +const guildAudioState = new Map(); +// Partymode: serverseitige Steuerung (global pro Guild) +const partyTimers = new Map(); +const partyActive = new Set(); +// Now-Playing: aktuell gespielter Sound pro Guild +const nowPlaying = new Map(); +// Verbindungszeitpunkt pro Guild (fuer Uptime-Anzeige im Frontend) +const connectedSince = new Map(); +// SSE-Klienten für Broadcasts (z.B. Partymode Status) +const sseClients = new Set(); +function sseBroadcast(payload: any) { + const data = `data: ${JSON.stringify(payload)}\n\n`; + for (const res of sseClients) { + try { res.write(data); } catch {} + } +} + +// 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 }); +} + +async function playFilePath(guildId: string, channelId: string, filePath: string, volume?: number, relativeKey?: string): Promise { + 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); + } + // 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 + }); + // Reuse bestehenden Player falls vorhanden + 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 {} + + // 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); + } + const useVolume = typeof volume === 'number' && Number.isFinite(volume) + ? Math.max(0, Math.min(1, volume)) + : (state.currentVolume ?? 1); + let resource: AudioResource; + if (NORMALIZE_ENABLE) { + const cachedPath = getNormCachePath(filePath); + if (cachedPath) { + // Cache-Hit: PCM aus RAM lesen (kein Disk-I/O, instant) + const pcmBuf = getPcmFromMemory(cachedPath); + if (pcmBuf) { + const useInline = useVolume !== 1; + resource = createAudioResource(Readable.from(pcmBuf), { + inlineVolume: useInline, + inputType: StreamType.Raw + }); + } else { + // Fallback: Stream von Disk + const pcmStream = fs.createReadStream(cachedPath, { highWaterMark: 256 * 1024 }); + 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)}`); + // In Memory-Cache laden fuer naechsten Aufruf + try { + const buf = fs.readFileSync(cacheFile); + if (pcmMemoryCacheBytes + buf.byteLength <= PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) { + pcmMemoryCache.set(cacheFile, buf); + pcmMemoryCacheBytes += buf.byteLength; + } + } catch {} + }); + 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 }); + } + if (resource.volume) resource.volume.setVolume(useVolume); + state.player.stop(); + 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); +} + +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' + + '?list - listet alle Audio-Dateien (mp3/wav)\n' + + '?entrance | remove - setze oder entferne deinen Entrance-Sound\n' + + '?exit | remove - setze oder entferne deinen Exit-Sound\n' + ); + return; + } + if (cmd === '?list') { + const files = fs + .readdirSync(SOUNDS_DIR) + .filter(f => { const l = f.toLowerCase(); return l.endsWith('.mp3') || l.endsWith('.wav'); }); + await reply(files.length ? files.join('\n') : 'Keine Dateien gefunden.'); + return; + } + if (cmd === '?entrance') { + 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 | 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; + } + 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; } + 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 {} + await reply(`Entrance-Sound gesetzt: ${resolve}`); return; + } + if (cmd === '?exit') { + 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 | 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; + } + 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; } + 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 {} + await reply(`Exit-Sound gesetzt: ${resolve}`); return; + } + await reply('Unbekannter Command. Nutze ?help.'); +} + +async function ensureConnectionReady(connection: VoiceConnection, channelId: string, guildId: string, guild: any): Promise { + // Versuch 1: Warten ob bestehende Connection ready wird + 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...`); + } + + // Versuch 2: Rejoin + 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.warn(`${new Date().toISOString()} | VoiceConnection still not ready after rejoin`); + } + + // Versuch 3: Komplett neu verbinden + try { connection.destroy(); } catch {} + guildAudioState.delete(guildId); + + const newConn = joinVoiceChannel({ + channelId, + guildId, + adapterCreator: guild.voiceAdapterCreator as any, + selfMute: false, + selfDeaf: false + }); + try { + await entersState(newConn, VoiceConnectionStatus.Ready, 15_000); + console.log(`${new Date().toISOString()} | VoiceConnection ready after fresh join`); + return newConn; + } catch (e3) { + console.error(`${new Date().toISOString()} | VoiceConnection failed after all attempts, cleaning up`); + try { newConn.destroy(); } catch {} + guildAudioState.delete(guildId); + throw new Error('Voice connection failed after 3 attempts'); + } +} + +function attachVoiceLifecycle(state: GuildAudioState, guild: any) { + const { connection } = state; + // Mehrfach-Registrierung verhindern + if ((connection as any).__lifecycleAttached) return; + try { (connection as any).setMaxListeners?.(0); } catch {} + + // Retry-Tracking um Endlosschleife zu verhindern + let reconnectAttempts = 0; + const MAX_RECONNECT_ATTEMPTS = 3; + let isReconnecting = false; + + connection.on('stateChange', async (oldS: any, newS: any) => { + console.log(`${new Date().toISOString()} | VoiceConnection: ${oldS.status} -> ${newS.status}`); + + // Ready zurückgesetzt -> Retry-Counter reset + if (newS.status === VoiceConnectionStatus.Ready) { + reconnectAttempts = 0; + isReconnecting = false; + if (!connectedSince.has(state.guildId)) connectedSince.set(state.guildId, new Date().toISOString()); + return; + } + + // Re-Entranz verhindern: wenn schon ein Reconnect läuft, nicht nochmal starten + if (isReconnecting) return; + + 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 { + if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + reconnectAttempts++; + console.warn(`${new Date().toISOString()} | Disconnected rejoin attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`); + connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); + } else { + console.error(`${new Date().toISOString()} | Max reconnect attempts reached from Disconnected, destroying and rejoining fresh`); + reconnectAttempts = 0; + try { connection.destroy(); } catch {} + 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.Destroyed) { + connectedSince.delete(state.guildId); + // 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) { + isReconnecting = true; + try { + await entersState(connection, VoiceConnectionStatus.Ready, 15_000); + // Ready wird oben im Handler behandelt + } catch (e) { + reconnectAttempts++; + if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + const backoffMs = reconnectAttempts * 2_000; + console.warn(`${new Date().toISOString()} | Voice not ready from ${newS.status}, attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}, backoff ${backoffMs}ms`); + await new Promise(r => setTimeout(r, backoffMs)); + isReconnecting = false; + connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); + } else { + console.error(`${new Date().toISOString()} | Max reconnect attempts reached, destroying and rejoining fresh`); + reconnectAttempts = 0; + isReconnecting = false; + try { connection.destroy(); } catch {} + 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); + } + } + } + } catch (e) { + isReconnecting = false; + console.error(`${new Date().toISOString()} | Voice lifecycle handler error`, e); + } + }); + (connection as any).__lifecycleAttached = true; +} + +client.once(Events.ClientReady, () => { + console.log(`Bot eingeloggt als ${client.user?.tag}`); +}); + +// Voice State Updates: Entrance/Exit +client.on(Events.VoiceStateUpdate, async (oldState: VoiceState, newState: VoiceState) => { + try { + const userId = (newState.id || oldState.id) as string; + if (!userId) return; + // Eigene Events ignorieren + if (userId === client.user?.id) return; + const guildId = (newState.guild?.id || oldState.guild?.id) as string; + if (!guildId) return; + + const before = oldState.channelId; + const after = newState.channelId; + console.log(`${new Date().toISOString()} | VoiceStateUpdate user=${userId} before=${before ?? '-'} after=${after ?? '-'}`); + + // 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}`); + 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)) { + try { + // Dem Channel beitreten und Sound spielen + await playFilePath(guildId, after, abs, undefined, rel); + console.log(`${new Date().toISOString()} | Entrance played for ${userId}: ${rel}`); + } catch (e) { console.warn('Entrance play error', e); } + } + } + } + // 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}`); + 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)) { + try { + await playFilePath(guildId, before, abs, undefined, rel); + console.log(`${new Date().toISOString()} | Exit played for ${userId}: ${rel}`); + } catch (e) { console.warn('Exit play error', e); } + } + } + } 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}`); + } + } catch (e) { + console.warn('VoiceStateUpdate entrance/exit handling error', e); + } +}); + +client.on(Events.MessageCreate, async (message: Message) => { + try { + if (message.author?.bot) return; + // Commands überall annehmen (inkl. DMs) + const content = (message.content || '').trim(); + if (content.startsWith('?')) { + await handleCommand(message, content); + return; + } + // Dateiuploads nur per DM + if (!message.channel?.isDMBased?.()) return; + if (message.attachments.size === 0) return; + + for (const [, attachment] of message.attachments) { + const name = attachment.name ?? 'upload'; + const lower = name.toLowerCase(); + if (!(lower.endsWith('.mp3') || lower.endsWith('.wav'))) continue; + + 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; + const ext = path.parse(safeName).ext || (lower.endsWith('.wav') ? '.wav' : '.mp3'); + 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)); + // 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) { + 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) => { + 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(); + 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 { + 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')) }); +}); + +app.get('/api/sounds', (req: Request, res: Response) => { + const q = String(req.query.q ?? '').toLowerCase(); + const folderFilter = typeof req.query.folder === 'string' ? (req.query.folder as string) : '__all__'; + const categoryFilter = typeof (req.query as any).categoryId === 'string' ? String((req.query as any).categoryId) : undefined; + const fuzzyParam = String((req.query as any).fuzzy ?? '0'); + const useFuzzy = fuzzyParam === '1' || fuzzyParam === 'true'; + + const allItems = listAllSounds(); + + // Ordner-Statistik aus allItems ableiten + const folderCounts = new Map(); + 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 }); + } + + // 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)); + let itemsByFolder = allItems; + if (folderFilter !== '__all__') { + 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)); + } + } + // 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) { + 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)); + } + } + + const total = allItems.length; + const recentCount = Math.min(10, total); + // 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 }>; + + const foldersOut = [ + { key: '__all__', name: 'Alle', count: total }, + { key: '__recent__', name: 'Neu', count: recentCount }, + ...(top3.length ? [{ key: '__top3__', name: 'Most Played (3)', count: top3.length }] : []), + ...folders + ]; + // isRecent-Flag für UI (Top 5 der neuesten) + // Kategorie-Filter (virtuell) anwenden, wenn gesetzt + let result = filteredItems; + if (categoryFilter) { + const fc = persistedState.fileCategories ?? {}; + result = result.filter((it) => { + const key = it.relativePath ?? it.fileName; + const cats = fc[key] ?? []; + return cats.includes(categoryFilter); + }); + } + if (folderFilter === '__top3__') { + const keys = new Set(top3.map(t => t.key.split(':')[1])); + result = allItems.filter(i => keys.has(i.relativePath ?? i.fileName)); + } + + // 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; + }); + + res.json({ items: withRecentFlag, total, folders: foldersOut, categories: persistedState.categories ?? [], fileCategories: persistedState.fileCategories ?? {} }); +}); + +// --- 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) { + 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' }); + } + } 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' }); + 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 = 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 ?? [] }); +}); + +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 }); +}); + +// 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 }); +}); + +// 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 }); +}); + +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); + const result: Array<{ guildId: string; guildName: string; channelId: string; channelName: string; selected?: boolean }> = []; + 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) { + const sel = getSelectedChannelForGuild(guild.id); + result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name, selected: sel === ch.id }); + } + } + } + result.sort((a, b) => a.guildName.localeCompare(b.guildName) || a.channelName.localeCompare(b.channelName)); + res.json(result); +}); + +// 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' }); + } +}); + +app.post('/api/play', async (req: Request, res: Response) => { + try { + const { soundName, guildId, channelId, volume, folder, relativePath } = req.body as { + soundName?: string; + guildId?: string; + channelId?: string; + volume?: number; // 0..1 + folder?: string; // optional subfolder key + relativePath?: string; // optional direct relative path + }; + if (!soundName || !guildId || !channelId) return res.status(400).json({ error: 'soundName, guildId, channelId erforderlich' }); + + let filePath: string; + if (relativePath) filePath = path.join(SOUNDS_DIR, relativePath); + 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; + } + if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Sound nicht gefunden' }); + + // 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); + return res.status(500).json({ error: err?.message ?? 'Unbekannter Fehler' }); + } +}); + +// 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) { + // Kein aktiver Player: nur persistieren für nächste Wiedergabe + persistedState.volumes[guildId] = safeVolume; + writePersistedState(persistedState); + // Broadcast neue Lautstärke an alle Clients + sseBroadcast({ type: 'volume', guildId, volume: safeVolume }); + return res.json({ ok: true, volume: safeVolume, persistedOnly: true }); + } + state.currentVolume = safeVolume; + if (state.currentResource?.volume) { + state.currentResource.volume.setVolume(safeVolume); + console.log(`${new Date().toISOString()} | live setVolume(${safeVolume}) guild=${guildId}`); + } + persistedState.volumes[guildId] = safeVolume; + writePersistedState(persistedState); + // Broadcast neue Lautstärke an alle Clients + sseBroadcast({ type: 'volume', guildId, volume: safeVolume }); + 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' }); + } +}); + +// 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 }); +}); + +// 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); + // 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); + if (t) clearTimeout(t); + partyTimers.delete(guildId); + partyActive.delete(guildId); + sseBroadcast({ type: 'party', guildId, active: false }); + } 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(); + // Broadcast Status + sseBroadcast({ type: 'party', guildId, active: true, channelId }); +} + +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); + sseBroadcast({ type: 'party', guildId: id, active: false }); + return res.json({ ok: true }); + } catch (e: any) { + console.error('party/stop error', e); + return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' }); + } +}); + +// 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 + try { + const statsSnap: Record = {}; + for (const [gId, st] of guildAudioState) { + const ch = client.channels.cache.get(st.channelId); + statsSnap[gId] = { + voicePing: (st.connection.ping as any)?.ws ?? null, + gatewayPing: client.ws.ping, + status: st.connection.state?.status ?? 'unknown', + channelName: ch && 'name' in ch ? (ch as any).name : null, + connectedSince: connectedSince.get(gId) ?? null, + }; + } + res.write(`data: ${JSON.stringify({ type: 'snapshot', party: Array.from(partyActive), selected: persistedState.selectedChannels ?? {}, volumes: persistedState.volumes ?? {}, nowplaying: Object.fromEntries(nowPlaying), voicestats: statsSnap })}\n\n`); + } catch {} + + // Ping, damit Proxies die Verbindung offen halten + const ping = setInterval(() => { try { res.write(':\n\n'); } catch {} }, 15_000); + + sseClients.add(res); + req.on('close', () => { + sseClients.delete(res); + clearInterval(ping); + try { res.end(); } catch {} + }); +}); + +// --- 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)) { + app.use(express.static(webDistPath)); + app.get('/{*splat}', (_req, res) => { + res.sendFile(path.join(webDistPath, 'index.html')); + }); +} + +// Node 24 warnt bei negativen Timeout-Werten (aus @discordjs/voice intern) - harmlos unterdruecken +process.on('warning', (warning) => { + if (warning.name === 'TimeoutNegativeWarning') return; + console.warn(warning.name + ': ' + warning.message); +}); +process.on('uncaughtException', (err) => { + console.error(`FATAL uncaughtException:`, err); + process.exit(1); +}); +process.on('unhandledRejection', (reason) => { + console.error(`FATAL unhandledRejection:`, reason); +}); + +app.listen(PORT, () => { + console.log(`Server läuft auf http://0.0.0.0:${PORT}`); + + // Vollständige Cache-Synchronisation beim Start (Hintergrund) + syncNormCache(); + + // Voice-Stats alle 5 Sekunden an alle SSE-Clients broadcasten + setInterval(() => { + if (sseClients.size === 0 || guildAudioState.size === 0) return; + for (const [gId, st] of guildAudioState) { + const ch = client.channels.cache.get(st.channelId); + sseBroadcast({ + type: 'voicestats', + guildId: gId, + voicePing: (st.connection.ping as any)?.ws ?? null, + gatewayPing: client.ws.ping, + status: st.connection.state?.status ?? 'unknown', + channelName: ch && 'name' in ch ? (ch as any).name : null, + connectedSince: connectedSince.get(gId) ?? null, + }); + } + }, 5_000); +}); + + + + diff --git a/web/src/App.tsx b/web/src/App.tsx index fb30ea6..98ad596 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,1167 +1,1257 @@ -import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; -import { - fetchChannels, fetchSounds, fetchAnalytics, playSound, playUrl, setVolumeLive, getVolume, - adminStatus, adminLogin, adminLogout, adminDelete, adminRename, - fetchCategories, partyStart, partyStop, subscribeEvents, - getSelectedChannels, setSelectedChannel, uploadFile, -} from './api'; -import type { VoiceChannelInfo, Sound, Category, AnalyticsResponse } from './types'; -import { getCookie, setCookie } from './cookies'; - -const THEMES = [ - { id: 'default', color: '#5865f2', label: 'Discord' }, - { id: 'purple', color: '#9b59b6', label: 'Midnight' }, - { id: 'forest', color: '#2ecc71', label: 'Forest' }, - { id: 'sunset', color: '#e67e22', label: 'Sunset' }, - { id: 'ocean', color: '#3498db', label: 'Ocean' }, -]; - -const CAT_PALETTE = [ - '#3b82f6', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6', - '#f97316', '#06b6d4', '#ef4444', '#a855f7', '#84cc16', - '#d946ef', '#0ea5e9', '#f43f5e', '#10b981', -]; - -type Tab = 'all' | 'favorites' | 'recent'; - -type UploadItem = { - id: string; - file: File; - status: 'waiting' | 'uploading' | 'done' | 'error'; - progress: number; - savedName?: string; - error?: string; -}; - -export default function App() { - /* ── Data ── */ - const [sounds, setSounds] = useState([]); - const [total, setTotal] = useState(0); - const [folders, setFolders] = useState>([]); - const [categories, setCategories] = useState([]); - const [analytics, setAnalytics] = useState({ - totalSounds: 0, - totalPlays: 0, - mostPlayed: [], - }); - - /* ── Navigation ── */ - const [activeTab, setActiveTab] = useState('all'); - const [activeFolder, setActiveFolder] = useState(''); - const [query, setQuery] = useState(''); - const [importUrl, setImportUrl] = useState(''); - const [importBusy, setImportBusy] = useState(false); - - /* ── Channels ── */ - const [channels, setChannels] = useState([]); - const [selected, setSelected] = useState(''); - const selectedRef = useRef(''); - const [channelOpen, setChannelOpen] = useState(false); - - /* ── Playback ── */ - const [volume, setVolume] = useState(1); - const [lastPlayed, setLastPlayed] = useState(''); - - /* ── Preferences ── */ - const [favs, setFavs] = useState>({}); - const [theme, setTheme] = useState(() => localStorage.getItem('jb-theme') || 'default'); - const [cardSize, setCardSize] = useState(() => parseInt(localStorage.getItem('jb-card-size') || '110')); - - /* ── Party ── */ - const [chaosMode, setChaosMode] = useState(false); - const [partyActiveGuilds, setPartyActiveGuilds] = useState([]); - const chaosModeRef = useRef(false); - const volDebounceRef = useRef>(); - - /* ── Admin ── */ - const [isAdmin, setIsAdmin] = useState(false); - const [showAdmin, setShowAdmin] = useState(false); - const [adminPwd, setAdminPwd] = useState(''); - const [adminSounds, setAdminSounds] = useState([]); - const [adminLoading, setAdminLoading] = useState(false); - const [adminQuery, setAdminQuery] = useState(''); - const [adminSelection, setAdminSelection] = useState>({}); - const [renameTarget, setRenameTarget] = useState(''); - const [renameValue, setRenameValue] = useState(''); - - /* ── Drag & Drop Upload ── */ - const [isDragging, setIsDragging] = useState(false); - const [uploads, setUploads] = useState([]); - const [showUploads, setShowUploads] = useState(false); - const dragCounterRef = useRef(0); - const uploadDismissRef = useRef>(); - - /* ── UI ── */ - const [notification, setNotification] = useState<{ msg: string; type: 'info' | 'error' } | null>(null); - const [clock, setClock] = useState(''); - const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; sound: Sound } | null>(null); - const [refreshKey, setRefreshKey] = useState(0); - - /* ── Refs ── */ - useEffect(() => { chaosModeRef.current = chaosMode; }, [chaosMode]); - useEffect(() => { selectedRef.current = selected; }, [selected]); - - /* ── Drag & Drop: globale Window-Listener ── */ - useEffect(() => { - const onDragEnter = (e: DragEvent) => { - if (Array.from(e.dataTransfer?.items ?? []).some(i => i.kind === 'file')) { - dragCounterRef.current++; - setIsDragging(true); - } - }; - const onDragLeave = () => { - dragCounterRef.current = Math.max(0, dragCounterRef.current - 1); - if (dragCounterRef.current === 0) setIsDragging(false); - }; - const onDragOver = (e: DragEvent) => e.preventDefault(); - const onDrop = (e: DragEvent) => { - e.preventDefault(); - dragCounterRef.current = 0; - setIsDragging(false); - const files = Array.from(e.dataTransfer?.files ?? []).filter(f => - /\.(mp3|wav)$/i.test(f.name) - ); - if (files.length) handleFileDrop(files); - }; - window.addEventListener('dragenter', onDragEnter); - window.addEventListener('dragleave', onDragLeave); - window.addEventListener('dragover', onDragOver); - window.addEventListener('drop', onDrop); - return () => { - window.removeEventListener('dragenter', onDragEnter); - window.removeEventListener('dragleave', onDragLeave); - window.removeEventListener('dragover', onDragOver); - window.removeEventListener('drop', onDrop); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isAdmin]); - - /* ── Helpers ── */ - const notify = useCallback((msg: string, type: 'info' | 'error' = 'info') => { - setNotification({ msg, type }); - setTimeout(() => setNotification(null), 3000); - }, []); - const soundKey = useCallback((s: Sound) => s.relativePath ?? s.fileName, []); - const isMp3Url = useCallback((value: string) => { - try { - const parsed = new URL(value.trim()); - return parsed.pathname.toLowerCase().endsWith('.mp3'); - } catch { - return false; - } - }, []); - - const guildId = selected ? selected.split(':')[0] : ''; - const channelId = selected ? selected.split(':')[1] : ''; - - const selectedChannel = useMemo(() => - channels.find(c => `${c.guildId}:${c.channelId}` === selected), - [channels, selected]); - - /* ── Clock ── */ - useEffect(() => { - const update = () => { - const now = new Date(); - const h = String(now.getHours()).padStart(2, '0'); - const m = String(now.getMinutes()).padStart(2, '0'); - const s = String(now.getSeconds()).padStart(2, '0'); - setClock(`${h}:${m}:${s}`); - }; - update(); - const id = setInterval(update, 1000); - return () => clearInterval(id); - }, []); - - /* ── Init ── */ - useEffect(() => { - (async () => { - try { - const [ch, selMap] = await Promise.all([fetchChannels(), getSelectedChannels()]); - setChannels(ch); - if (ch.length) { - const g = ch[0].guildId; - const serverCid = selMap[g]; - const match = serverCid && ch.find(x => x.guildId === g && x.channelId === serverCid); - setSelected(match ? `${g}:${serverCid}` : `${ch[0].guildId}:${ch[0].channelId}`); - } - } catch (e: any) { notify(e?.message || 'Channel-Fehler', 'error'); } - try { setIsAdmin(await adminStatus()); } catch { } - try { const c = await fetchCategories(); setCategories(c.categories || []); } catch { } - })(); - }, []); - - /* ── Theme ── */ - useEffect(() => { - if (theme === 'default') document.body.removeAttribute('data-theme'); - else document.body.setAttribute('data-theme', theme); - localStorage.setItem('jb-theme', theme); - }, [theme]); - - /* ── Card size ── */ - useEffect(() => { - const r = document.documentElement; - r.style.setProperty('--card-size', cardSize + 'px'); - const ratio = cardSize / 110; - r.style.setProperty('--card-emoji', Math.round(28 * ratio) + 'px'); - r.style.setProperty('--card-font', Math.max(9, Math.round(11 * ratio)) + 'px'); - localStorage.setItem('jb-card-size', String(cardSize)); - }, [cardSize]); - - /* ── SSE ── */ - useEffect(() => { - const unsub = subscribeEvents((msg) => { - if (msg?.type === 'party') { - setPartyActiveGuilds(prev => { - const s = new Set(prev); - if (msg.active) s.add(msg.guildId); else s.delete(msg.guildId); - return Array.from(s); - }); - } else if (msg?.type === 'snapshot') { - setPartyActiveGuilds(Array.isArray(msg.party) ? msg.party : []); - try { - const sel = msg?.selected || {}; - const g = selectedRef.current?.split(':')[0]; - if (g && sel[g]) setSelected(`${g}:${sel[g]}`); - } catch { } - try { - const vols = msg?.volumes || {}; - const g = selectedRef.current?.split(':')[0]; - if (g && typeof vols[g] === 'number') setVolume(vols[g]); - } catch { } - try { - const np = msg?.nowplaying || {}; - const g = selectedRef.current?.split(':')[0]; - if (g && typeof np[g] === 'string') setLastPlayed(np[g]); - } catch { } - } else if (msg?.type === 'channel') { - const g = selectedRef.current?.split(':')[0]; - if (msg.guildId === g) setSelected(`${msg.guildId}:${msg.channelId}`); - } else if (msg?.type === 'volume') { - const g = selectedRef.current?.split(':')[0]; - if (msg.guildId === g && typeof msg.volume === 'number') setVolume(msg.volume); - } else if (msg?.type === 'nowplaying') { - const g = selectedRef.current?.split(':')[0]; - if (msg.guildId === g) setLastPlayed(msg.name || ''); - } - }); - return () => { try { unsub(); } catch { } }; - }, []); - - useEffect(() => { - setChaosMode(guildId ? partyActiveGuilds.includes(guildId) : false); - }, [selected, partyActiveGuilds]); - - /* ── Data Fetch ── */ - useEffect(() => { - (async () => { - try { - let folderParam = '__all__'; - if (activeTab === 'recent') folderParam = '__recent__'; - else if (activeFolder) folderParam = activeFolder; - const s = await fetchSounds(query, folderParam, undefined, false); - setSounds(s.items); - setTotal(s.total); - setFolders(s.folders); - } catch (e: any) { notify(e?.message || 'Sounds-Fehler', 'error'); } - })(); - }, [activeTab, activeFolder, query, refreshKey]); - - useEffect(() => { - void loadAnalytics(); - }, [refreshKey]); - - /* ── Favs persistence ── */ - useEffect(() => { - const c = getCookie('favs'); - if (c) try { setFavs(JSON.parse(c)); } catch { } - }, []); - - useEffect(() => { - try { setCookie('favs', JSON.stringify(favs)); } catch { } - }, [favs]); - - /* ── Volume sync ── */ - useEffect(() => { - if (selected) { - (async () => { - try { const v = await getVolume(guildId); setVolume(v); } catch { } - })(); - } - }, [selected]); - - /* ── Close dropdowns on outside click ── */ - useEffect(() => { - const handler = () => { setChannelOpen(false); setCtxMenu(null); }; - document.addEventListener('click', handler); - return () => document.removeEventListener('click', handler); - }, []); - - useEffect(() => { - if (showAdmin && isAdmin) { - void loadAdminSounds(); - } - }, [showAdmin, isAdmin]); - - /* ── Actions ── */ - async function loadAnalytics() { - try { - const data = await fetchAnalytics(); - setAnalytics(data); - } catch { } - } - - async function handlePlay(s: Sound) { - if (!selected) return notify('Bitte einen Voice-Channel auswählen', 'error'); - try { - await playSound(s.name, guildId, channelId, volume, s.relativePath); - setLastPlayed(s.name); - void loadAnalytics(); - } catch (e: any) { notify(e?.message || 'Play fehlgeschlagen', 'error'); } - } - - async function handleUrlImport() { - const trimmed = importUrl.trim(); - if (!trimmed) return notify('Bitte einen MP3-Link eingeben', 'error'); - if (!selected) return notify('Bitte einen Voice-Channel auswählen', 'error'); - if (!isMp3Url(trimmed)) return notify('Nur direkte MP3-Links sind erlaubt', 'error'); - setImportBusy(true); - try { - await playUrl(trimmed, guildId, channelId, volume); - setImportUrl(''); - notify('MP3 importiert und abgespielt'); - setRefreshKey(k => k + 1); - await loadAnalytics(); - } catch (e: any) { - notify(e?.message || 'URL-Import fehlgeschlagen', 'error'); - } finally { - setImportBusy(false); - } - } - - async function handleFileDrop(files: File[]) { - if (!isAdmin) { - notify('Admin-Login erforderlich zum Hochladen', 'error'); - return; - } - if (uploadDismissRef.current) clearTimeout(uploadDismissRef.current); - - const items: UploadItem[] = files.map(f => ({ - id: Math.random().toString(36).slice(2), - file: f, - status: 'waiting', - progress: 0, - })); - setUploads(items); - setShowUploads(true); - - const updated = [...items]; - for (let i = 0; i < updated.length; i++) { - updated[i] = { ...updated[i], status: 'uploading' }; - setUploads([...updated]); - try { - const savedName = await uploadFile( - updated[i].file, - pct => { - updated[i] = { ...updated[i], progress: pct }; - setUploads([...updated]); - }, - ); - updated[i] = { ...updated[i], status: 'done', progress: 100, savedName }; - } catch (e: any) { - updated[i] = { ...updated[i], status: 'error', error: e?.message ?? 'Fehler' }; - } - setUploads([...updated]); - } - - // Sound-Liste aktualisieren - setRefreshKey(k => k + 1); - void loadAnalytics(); - - // Auto-Dismiss nach 3s - uploadDismissRef.current = setTimeout(() => { - setShowUploads(false); - setUploads([]); - }, 3500); - } - - async function handleStop() { - if (!selected) return; - setLastPlayed(''); - try { await fetch(`/api/stop?guildId=${encodeURIComponent(guildId)}`, { method: 'POST' }); } catch { } - } - - async function handleRandom() { - if (!displaySounds.length || !selected) return; - const rnd = displaySounds[Math.floor(Math.random() * displaySounds.length)]; - handlePlay(rnd); - } - - async function toggleParty() { - if (chaosMode) { - await handleStop(); - try { await partyStop(guildId); } catch { } - } else { - if (!selected) return notify('Bitte einen Channel auswählen', 'error'); - try { await partyStart(guildId, channelId); } catch { } - } - } - - async function handleChannelSelect(ch: VoiceChannelInfo) { - const v = `${ch.guildId}:${ch.channelId}`; - setSelected(v); - setChannelOpen(false); - try { await setSelectedChannel(ch.guildId, ch.channelId); } catch { } - } - - function toggleFav(key: string) { - setFavs(prev => ({ ...prev, [key]: !prev[key] })); - } - - async function loadAdminSounds() { - setAdminLoading(true); - try { - const data = await fetchSounds('', '__all__', undefined, false); - setAdminSounds(data.items || []); - } catch (e: any) { - notify(e?.message || 'Admin-Sounds konnten nicht geladen werden', 'error'); - } finally { - setAdminLoading(false); - } - } - - function toggleAdminSelection(path: string) { - setAdminSelection(prev => ({ ...prev, [path]: !prev[path] })); - } - - function startRename(sound: Sound) { - setRenameTarget(soundKey(sound)); - setRenameValue(sound.name); - } - - function cancelRename() { - setRenameTarget(''); - setRenameValue(''); - } - - async function submitRename() { - if (!renameTarget) return; - const baseName = renameValue.trim().replace(/\.(mp3|wav)$/i, ''); - if (!baseName) { - notify('Bitte einen gültigen Namen eingeben', 'error'); - return; - } - try { - await adminRename(renameTarget, baseName); - notify('Sound umbenannt'); - cancelRename(); - setRefreshKey(k => k + 1); - if (showAdmin) await loadAdminSounds(); - } catch (e: any) { - notify(e?.message || 'Umbenennen fehlgeschlagen', 'error'); - } - } - - async function deleteAdminPaths(paths: string[]) { - if (paths.length === 0) return; - try { - await adminDelete(paths); - notify(paths.length === 1 ? 'Sound gelöscht' : `${paths.length} Sounds gelöscht`); - setAdminSelection({}); - cancelRename(); - setRefreshKey(k => k + 1); - if (showAdmin) await loadAdminSounds(); - } catch (e: any) { - notify(e?.message || 'Löschen fehlgeschlagen', 'error'); - } - } - - async function handleAdminLogin() { - try { - const ok = await adminLogin(adminPwd); - if (ok) { - setIsAdmin(true); - setAdminPwd(''); - notify('Admin eingeloggt'); - } - else notify('Falsches Passwort', 'error'); - } catch { notify('Login fehlgeschlagen', 'error'); } - } - - async function handleAdminLogout() { - try { - await adminLogout(); - setIsAdmin(false); - setAdminSelection({}); - cancelRename(); - notify('Ausgeloggt'); - } catch { } - } - - /* ── Computed ── */ - const displaySounds = useMemo(() => { - if (activeTab === 'favorites') return sounds.filter(s => favs[s.relativePath ?? s.fileName]); - return sounds; - }, [sounds, activeTab, favs]); - - const favCount = useMemo(() => Object.values(favs).filter(Boolean).length, [favs]); - - const visibleFolders = useMemo(() => - folders.filter(f => !['__all__', '__recent__', '__top3__'].includes(f.key)), - [folders]); - - const folderColorMap = useMemo(() => { - const m: Record = {}; - visibleFolders.forEach((f, i) => { m[f.key] = CAT_PALETTE[i % CAT_PALETTE.length]; }); - return m; - }, [visibleFolders]); - - const firstOfInitial = useMemo(() => { - const seen = new Set(); - const result = new Set(); - displaySounds.forEach((s, idx) => { - const ch = s.name.charAt(0).toUpperCase(); - if (!seen.has(ch)) { seen.add(ch); result.add(idx); } - }); - return result; - }, [displaySounds]); - - const channelsByGuild = useMemo(() => { - const groups: Record = {}; - channels.forEach(c => { - if (!groups[c.guildName]) groups[c.guildName] = []; - groups[c.guildName].push(c); - }); - return groups; - }, [channels]); - - const adminFilteredSounds = useMemo(() => { - const q = adminQuery.trim().toLowerCase(); - if (!q) return adminSounds; - return adminSounds.filter(s => { - const key = soundKey(s).toLowerCase(); - return s.name.toLowerCase().includes(q) - || (s.folder || '').toLowerCase().includes(q) - || key.includes(q); - }); - }, [adminQuery, adminSounds, soundKey]); - - const selectedAdminPaths = useMemo(() => - Object.keys(adminSelection).filter(k => adminSelection[k]), - [adminSelection]); - - const selectedVisibleCount = useMemo(() => - adminFilteredSounds.filter(s => !!adminSelection[soundKey(s)]).length, - [adminFilteredSounds, adminSelection, soundKey]); - - const allVisibleSelected = adminFilteredSounds.length > 0 && selectedVisibleCount === adminFilteredSounds.length; - const analyticsTop = analytics.mostPlayed.slice(0, 10); - const totalSoundsDisplay = analytics.totalSounds || total; - - const clockMain = clock.slice(0, 5); - const clockSec = clock.slice(5); - - /* ════════════════════════════════════════════ - RENDER - ════════════════════════════════════════════ */ - return ( -
- {chaosMode &&
} - - {/* ═══ TOPBAR ═══ */} -
-
-
- music_note -
- Soundboard - - {/* Channel Dropdown */} -
e.stopPropagation()}> - - {channelOpen && ( -
- {Object.entries(channelsByGuild).map(([guild, chs]) => ( - -
{guild}
- {chs.map(ch => ( -
handleChannelSelect(ch)} - > - volume_up - {ch.channelName} -
- ))} -
- ))} - {channels.length === 0 && ( -
- Keine Channels verfügbar -
- )} -
- )} -
-
- -
-
{clockMain}{clockSec}
-
- -
- {lastPlayed && ( -
-
-
-
-
- Last Played: {lastPlayed} -
- )} - {selected && ( -
- - Verbunden -
- )} - -
-
- - {/* ═══ TOOLBAR ═══ */} -
-
- - - -
- -
- search - setQuery(e.target.value)} - /> - {query && ( - - )} -
- -
- link - setImportUrl(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') void handleUrlImport(); }} - /> - -
- -
- -
- { - const newVol = volume > 0 ? 0 : 0.5; - setVolume(newVol); - if (guildId) setVolumeLive(guildId, newVol).catch(() => {}); - }} - > - {volume === 0 ? 'volume_off' : volume < 0.5 ? 'volume_down' : 'volume_up'} - - { - const v = parseFloat(e.target.value); - setVolume(v); - if (guildId) { - if (volDebounceRef.current) clearTimeout(volDebounceRef.current); - volDebounceRef.current = setTimeout(() => { - setVolumeLive(guildId, v).catch(() => {}); - }, 120); - } - }} - style={{ '--vol': `${Math.round(volume * 100)}%` } as React.CSSProperties} - /> - {Math.round(volume * 100)}% -
- - - - - - - -
- grid_view - setCardSize(parseInt(e.target.value))} - /> -
- -
- {THEMES.map(t => ( -
setTheme(t.id)} - /> - ))} -
-
- -
-
- library_music -
- Sounds gesamt - {totalSoundsDisplay} -
-
- -
- leaderboard -
- Most Played -
- {analyticsTop.length === 0 ? ( - Noch keine Plays - ) : ( - analyticsTop.map((item, idx) => ( - - {idx + 1}. {item.name} ({item.count}) - - )) - )} -
-
-
-
- - {/* ═══ FOLDER CHIPS ═══ */} - {activeTab === 'all' && visibleFolders.length > 0 && ( -
- {visibleFolders.map(f => { - const color = folderColorMap[f.key] || '#888'; - const isActive = activeFolder === f.key; - return ( - - ); - })} -
- )} - - {/* ═══ MAIN ═══ */} -
- {displaySounds.length === 0 ? ( -
-
{activeTab === 'favorites' ? '⭐' : '🔇'}
-
- {activeTab === 'favorites' - ? 'Noch keine Favoriten' - : query - ? `Kein Sound für "${query}" gefunden` - : 'Keine Sounds vorhanden'} -
-
- {activeTab === 'favorites' - ? 'Klick den Stern auf einem Sound!' - : 'Hier gibt\'s noch nichts zu hören.'} -
-
- ) : ( -
- {displaySounds.map((s, idx) => { - const key = s.relativePath ?? s.fileName; - const isFav = !!favs[key]; - const isPlaying = lastPlayed === s.name; - const isNew = s.isRecent || s.badges?.includes('new'); - const initial = s.name.charAt(0).toUpperCase(); - const showInitial = firstOfInitial.has(idx); - const folderColor = s.folder ? (folderColorMap[s.folder] || 'var(--accent)') : 'var(--accent)'; - - return ( -
{ - const card = e.currentTarget; - const rect = card.getBoundingClientRect(); - const ripple = document.createElement('div'); - ripple.className = 'ripple'; - const sz = Math.max(rect.width, rect.height); - ripple.style.width = ripple.style.height = sz + 'px'; - ripple.style.left = (e.clientX - rect.left - sz / 2) + 'px'; - ripple.style.top = (e.clientY - rect.top - sz / 2) + 'px'; - card.appendChild(ripple); - setTimeout(() => ripple.remove(), 500); - handlePlay(s); - }} - onContextMenu={e => { - e.preventDefault(); - e.stopPropagation(); - setCtxMenu({ - x: Math.min(e.clientX, window.innerWidth - 170), - y: Math.min(e.clientY, window.innerHeight - 140), - sound: s, - }); - }} - title={`${s.name}${s.folder ? ` (${s.folder})` : ''}`} - > - {isNew && NEU} - { e.stopPropagation(); toggleFav(key); }} - > - {isFav ? 'star' : 'star_border'} - - {showInitial && {initial}} - {s.name} - {s.folder && {s.folder}} -
-
-
-
-
- ); - })} -
- )} -
- - {/* ═══ CONTEXT MENU ═══ */} - {ctxMenu && ( -
e.stopPropagation()} - > -
{ handlePlay(ctxMenu.sound); setCtxMenu(null); }}> - play_arrow - Abspielen -
-
{ - toggleFav(ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName); - setCtxMenu(null); - }}> - - {favs[ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName] ? 'star' : 'star_border'} - - Favorit -
- {isAdmin && ( - <> -
-
{ - const path = ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName; - await deleteAdminPaths([path]); - setCtxMenu(null); - }}> - delete - Löschen -
- - )} -
- )} - - {/* ═══ TOAST ═══ */} - {notification && ( -
- - {notification.type === 'error' ? 'error_outline' : 'check_circle'} - - {notification.msg} -
- )} - - {/* ═══ ADMIN PANEL ═══ */} - {showAdmin && ( -
{ if (e.target === e.currentTarget) setShowAdmin(false); }}> -
-

- Admin - -

- {!isAdmin ? ( -
-
- - setAdminPwd(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleAdminLogin()} - placeholder="Admin-Passwort..." - /> -
- -
- ) : ( -
-
-

Eingeloggt als Admin

-
- - -
-
- -
- - setAdminQuery(e.target.value)} - placeholder="Nach Name, Ordner oder Pfad filtern..." - /> -
- -
- - - -
- -
- {adminLoading ? ( -
Lade Sounds...
- ) : adminFilteredSounds.length === 0 ? ( -
Keine Sounds gefunden.
- ) : ( -
- {adminFilteredSounds.map(sound => { - const key = soundKey(sound); - const editing = renameTarget === key; - return ( -
- - -
-
{sound.name}
-
- {sound.folder ? `Ordner: ${sound.folder}` : 'Root'} - {' · '} - {key} -
- {editing && ( -
- setRenameValue(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter') void submitRename(); - if (e.key === 'Escape') cancelRename(); - }} - placeholder="Neuer Name..." - /> - - -
- )} -
- - {!editing && ( -
- - -
- )} -
- ); - })} -
- )} -
-
- )} -
-
- )} - - {/* ── Drag & Drop Overlay ── */} - {isDragging && ( -
-
- cloud_upload -
MP3 & WAV hier ablegen
-
Mehrere Dateien gleichzeitig möglich
-
-
- )} - - {/* ── Upload-Queue ── */} - {showUploads && uploads.length > 0 && ( -
-
- upload - - {uploads.every(u => u.status === 'done' || u.status === 'error') - ? `${uploads.filter(u => u.status === 'done').length} von ${uploads.length} hochgeladen` - : `Lade hoch… (${uploads.filter(u => u.status === 'done').length}/${uploads.length})`} - - -
-
- {uploads.map(u => ( -
- audio_file -
-
- {u.savedName ?? u.file.name} -
-
{(u.file.size / 1024).toFixed(0)} KB
-
- {(u.status === 'waiting' || u.status === 'uploading') && ( -
-
-
- )} - - {u.status === 'done' ? 'check_circle' : - u.status === 'error' ? 'error' : - u.status === 'uploading' ? 'sync' : 'schedule'} - - {u.status === 'error' &&
{u.error}
} -
- ))} -
-
- )} -
- ); -} +import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; +import { + fetchChannels, fetchSounds, fetchAnalytics, playSound, playUrl, setVolumeLive, getVolume, + adminStatus, adminLogin, adminLogout, adminDelete, adminRename, + fetchCategories, partyStart, partyStop, subscribeEvents, + getSelectedChannels, setSelectedChannel, uploadFile, +} from './api'; +import type { VoiceChannelInfo, Sound, Category, AnalyticsResponse } from './types'; +import { getCookie, setCookie } from './cookies'; + +const THEMES = [ + { id: 'default', color: '#5865f2', label: 'Discord' }, + { id: 'purple', color: '#9b59b6', label: 'Midnight' }, + { id: 'forest', color: '#2ecc71', label: 'Forest' }, + { id: 'sunset', color: '#e67e22', label: 'Sunset' }, + { id: 'ocean', color: '#3498db', label: 'Ocean' }, +]; + +const CAT_PALETTE = [ + '#3b82f6', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6', + '#f97316', '#06b6d4', '#ef4444', '#a855f7', '#84cc16', + '#d946ef', '#0ea5e9', '#f43f5e', '#10b981', +]; + +type Tab = 'all' | 'favorites' | 'recent'; + +type UploadItem = { + id: string; + file: File; + status: 'waiting' | 'uploading' | 'done' | 'error'; + progress: number; + savedName?: string; + error?: string; +}; + +export default function App() { + /* ── Data ── */ + const [sounds, setSounds] = useState([]); + const [total, setTotal] = useState(0); + const [folders, setFolders] = useState>([]); + const [categories, setCategories] = useState([]); + const [analytics, setAnalytics] = useState({ + totalSounds: 0, + totalPlays: 0, + mostPlayed: [], + }); + + /* ── Navigation ── */ + const [activeTab, setActiveTab] = useState('all'); + const [activeFolder, setActiveFolder] = useState(''); + const [query, setQuery] = useState(''); + const [importUrl, setImportUrl] = useState(''); + const [importBusy, setImportBusy] = useState(false); + + /* ── Channels ── */ + const [channels, setChannels] = useState([]); + const [selected, setSelected] = useState(''); + const selectedRef = useRef(''); + const [channelOpen, setChannelOpen] = useState(false); + + /* ── Playback ── */ + const [volume, setVolume] = useState(1); + const [lastPlayed, setLastPlayed] = useState(''); + + /* ── Preferences ── */ + const [favs, setFavs] = useState>({}); + const [theme, setTheme] = useState(() => localStorage.getItem('jb-theme') || 'default'); + const [cardSize, setCardSize] = useState(() => parseInt(localStorage.getItem('jb-card-size') || '110')); + + /* ── Party ── */ + const [chaosMode, setChaosMode] = useState(false); + const [partyActiveGuilds, setPartyActiveGuilds] = useState([]); + const chaosModeRef = useRef(false); + const volDebounceRef = useRef>(); + + /* ── Admin ── */ + const [isAdmin, setIsAdmin] = useState(false); + const [showAdmin, setShowAdmin] = useState(false); + const [adminPwd, setAdminPwd] = useState(''); + const [adminSounds, setAdminSounds] = useState([]); + const [adminLoading, setAdminLoading] = useState(false); + const [adminQuery, setAdminQuery] = useState(''); + const [adminSelection, setAdminSelection] = useState>({}); + const [renameTarget, setRenameTarget] = useState(''); + const [renameValue, setRenameValue] = useState(''); + + /* ── Drag & Drop Upload ── */ + const [isDragging, setIsDragging] = useState(false); + const [uploads, setUploads] = useState([]); + const [showUploads, setShowUploads] = useState(false); + const dragCounterRef = useRef(0); + const uploadDismissRef = useRef>(); + + /* ── Voice Stats ── */ + interface VoiceStats { + voicePing: number | null; + gatewayPing: number; + status: string; + channelName: string | null; + connectedSince: string | null; + } + const [voiceStats, setVoiceStats] = useState(null); + const [showConnModal, setShowConnModal] = useState(false); + + /* ── UI ── */ + const [notification, setNotification] = useState<{ msg: string; type: 'info' | 'error' } | null>(null); + const [clock, setClock] = useState(''); + const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; sound: Sound } | null>(null); + const [refreshKey, setRefreshKey] = useState(0); + + /* ── Refs ── */ + useEffect(() => { chaosModeRef.current = chaosMode; }, [chaosMode]); + useEffect(() => { selectedRef.current = selected; }, [selected]); + + /* ── Drag & Drop: globale Window-Listener ── */ + useEffect(() => { + const onDragEnter = (e: DragEvent) => { + if (Array.from(e.dataTransfer?.items ?? []).some(i => i.kind === 'file')) { + dragCounterRef.current++; + setIsDragging(true); + } + }; + const onDragLeave = () => { + dragCounterRef.current = Math.max(0, dragCounterRef.current - 1); + if (dragCounterRef.current === 0) setIsDragging(false); + }; + const onDragOver = (e: DragEvent) => e.preventDefault(); + const onDrop = (e: DragEvent) => { + e.preventDefault(); + dragCounterRef.current = 0; + setIsDragging(false); + const files = Array.from(e.dataTransfer?.files ?? []).filter(f => + /\.(mp3|wav)$/i.test(f.name) + ); + if (files.length) handleFileDrop(files); + }; + window.addEventListener('dragenter', onDragEnter); + window.addEventListener('dragleave', onDragLeave); + window.addEventListener('dragover', onDragOver); + window.addEventListener('drop', onDrop); + return () => { + window.removeEventListener('dragenter', onDragEnter); + window.removeEventListener('dragleave', onDragLeave); + window.removeEventListener('dragover', onDragOver); + window.removeEventListener('drop', onDrop); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAdmin]); + + /* ── Helpers ── */ + const notify = useCallback((msg: string, type: 'info' | 'error' = 'info') => { + setNotification({ msg, type }); + setTimeout(() => setNotification(null), 3000); + }, []); + const soundKey = useCallback((s: Sound) => s.relativePath ?? s.fileName, []); + const isMp3Url = useCallback((value: string) => { + try { + const parsed = new URL(value.trim()); + return parsed.pathname.toLowerCase().endsWith('.mp3'); + } catch { + return false; + } + }, []); + + const guildId = selected ? selected.split(':')[0] : ''; + const channelId = selected ? selected.split(':')[1] : ''; + + const selectedChannel = useMemo(() => + channels.find(c => `${c.guildId}:${c.channelId}` === selected), + [channels, selected]); + + /* ── Clock ── */ + useEffect(() => { + const update = () => { + const now = new Date(); + const h = String(now.getHours()).padStart(2, '0'); + const m = String(now.getMinutes()).padStart(2, '0'); + const s = String(now.getSeconds()).padStart(2, '0'); + setClock(`${h}:${m}:${s}`); + }; + update(); + const id = setInterval(update, 1000); + return () => clearInterval(id); + }, []); + + /* ── Init ── */ + useEffect(() => { + (async () => { + try { + const [ch, selMap] = await Promise.all([fetchChannels(), getSelectedChannels()]); + setChannels(ch); + if (ch.length) { + const g = ch[0].guildId; + const serverCid = selMap[g]; + const match = serverCid && ch.find(x => x.guildId === g && x.channelId === serverCid); + setSelected(match ? `${g}:${serverCid}` : `${ch[0].guildId}:${ch[0].channelId}`); + } + } catch (e: any) { notify(e?.message || 'Channel-Fehler', 'error'); } + try { setIsAdmin(await adminStatus()); } catch { } + try { const c = await fetchCategories(); setCategories(c.categories || []); } catch { } + })(); + }, []); + + /* ── Theme ── */ + useEffect(() => { + if (theme === 'default') document.body.removeAttribute('data-theme'); + else document.body.setAttribute('data-theme', theme); + localStorage.setItem('jb-theme', theme); + }, [theme]); + + /* ── Card size ── */ + useEffect(() => { + const r = document.documentElement; + r.style.setProperty('--card-size', cardSize + 'px'); + const ratio = cardSize / 110; + r.style.setProperty('--card-emoji', Math.round(28 * ratio) + 'px'); + r.style.setProperty('--card-font', Math.max(9, Math.round(11 * ratio)) + 'px'); + localStorage.setItem('jb-card-size', String(cardSize)); + }, [cardSize]); + + /* ── SSE ── */ + useEffect(() => { + const unsub = subscribeEvents((msg) => { + if (msg?.type === 'party') { + setPartyActiveGuilds(prev => { + const s = new Set(prev); + if (msg.active) s.add(msg.guildId); else s.delete(msg.guildId); + return Array.from(s); + }); + } else if (msg?.type === 'snapshot') { + setPartyActiveGuilds(Array.isArray(msg.party) ? msg.party : []); + try { + const sel = msg?.selected || {}; + const g = selectedRef.current?.split(':')[0]; + if (g && sel[g]) setSelected(`${g}:${sel[g]}`); + } catch { } + try { + const vols = msg?.volumes || {}; + const g = selectedRef.current?.split(':')[0]; + if (g && typeof vols[g] === 'number') setVolume(vols[g]); + } catch { } + try { + const np = msg?.nowplaying || {}; + const g = selectedRef.current?.split(':')[0]; + if (g && typeof np[g] === 'string') setLastPlayed(np[g]); + } catch { } + try { + const vs = msg?.voicestats || {}; + const g = selectedRef.current?.split(':')[0]; + if (g && vs[g]) setVoiceStats(vs[g]); + } catch { } + } else if (msg?.type === 'channel') { + const g = selectedRef.current?.split(':')[0]; + if (msg.guildId === g) setSelected(`${msg.guildId}:${msg.channelId}`); + } else if (msg?.type === 'volume') { + const g = selectedRef.current?.split(':')[0]; + if (msg.guildId === g && typeof msg.volume === 'number') setVolume(msg.volume); + } else if (msg?.type === 'nowplaying') { + const g = selectedRef.current?.split(':')[0]; + if (msg.guildId === g) setLastPlayed(msg.name || ''); + } else if (msg?.type === 'voicestats') { + const g = selectedRef.current?.split(':')[0]; + if (msg.guildId === g) { + setVoiceStats({ + voicePing: msg.voicePing, + gatewayPing: msg.gatewayPing, + status: msg.status, + channelName: msg.channelName, + connectedSince: msg.connectedSince, + }); + } + } + }); + return () => { try { unsub(); } catch { } }; + }, []); + + useEffect(() => { + setChaosMode(guildId ? partyActiveGuilds.includes(guildId) : false); + }, [selected, partyActiveGuilds]); + + /* ── Data Fetch ── */ + useEffect(() => { + (async () => { + try { + let folderParam = '__all__'; + if (activeTab === 'recent') folderParam = '__recent__'; + else if (activeFolder) folderParam = activeFolder; + const s = await fetchSounds(query, folderParam, undefined, false); + setSounds(s.items); + setTotal(s.total); + setFolders(s.folders); + } catch (e: any) { notify(e?.message || 'Sounds-Fehler', 'error'); } + })(); + }, [activeTab, activeFolder, query, refreshKey]); + + useEffect(() => { + void loadAnalytics(); + }, [refreshKey]); + + /* ── Favs persistence ── */ + useEffect(() => { + const c = getCookie('favs'); + if (c) try { setFavs(JSON.parse(c)); } catch { } + }, []); + + useEffect(() => { + try { setCookie('favs', JSON.stringify(favs)); } catch { } + }, [favs]); + + /* ── Volume sync ── */ + useEffect(() => { + if (selected) { + (async () => { + try { const v = await getVolume(guildId); setVolume(v); } catch { } + })(); + } + }, [selected]); + + /* ── Close dropdowns on outside click ── */ + useEffect(() => { + const handler = () => { setChannelOpen(false); setCtxMenu(null); }; + document.addEventListener('click', handler); + return () => document.removeEventListener('click', handler); + }, []); + + useEffect(() => { + if (showAdmin && isAdmin) { + void loadAdminSounds(); + } + }, [showAdmin, isAdmin]); + + /* ── Actions ── */ + async function loadAnalytics() { + try { + const data = await fetchAnalytics(); + setAnalytics(data); + } catch { } + } + + async function handlePlay(s: Sound) { + if (!selected) return notify('Bitte einen Voice-Channel auswählen', 'error'); + try { + await playSound(s.name, guildId, channelId, volume, s.relativePath); + setLastPlayed(s.name); + void loadAnalytics(); + } catch (e: any) { notify(e?.message || 'Play fehlgeschlagen', 'error'); } + } + + async function handleUrlImport() { + const trimmed = importUrl.trim(); + if (!trimmed) return notify('Bitte einen MP3-Link eingeben', 'error'); + if (!selected) return notify('Bitte einen Voice-Channel auswählen', 'error'); + if (!isMp3Url(trimmed)) return notify('Nur direkte MP3-Links sind erlaubt', 'error'); + setImportBusy(true); + try { + await playUrl(trimmed, guildId, channelId, volume); + setImportUrl(''); + notify('MP3 importiert und abgespielt'); + setRefreshKey(k => k + 1); + await loadAnalytics(); + } catch (e: any) { + notify(e?.message || 'URL-Import fehlgeschlagen', 'error'); + } finally { + setImportBusy(false); + } + } + + async function handleFileDrop(files: File[]) { + if (!isAdmin) { + notify('Admin-Login erforderlich zum Hochladen', 'error'); + return; + } + if (uploadDismissRef.current) clearTimeout(uploadDismissRef.current); + + const items: UploadItem[] = files.map(f => ({ + id: Math.random().toString(36).slice(2), + file: f, + status: 'waiting', + progress: 0, + })); + setUploads(items); + setShowUploads(true); + + const updated = [...items]; + for (let i = 0; i < updated.length; i++) { + updated[i] = { ...updated[i], status: 'uploading' }; + setUploads([...updated]); + try { + const savedName = await uploadFile( + updated[i].file, + pct => { + updated[i] = { ...updated[i], progress: pct }; + setUploads([...updated]); + }, + ); + updated[i] = { ...updated[i], status: 'done', progress: 100, savedName }; + } catch (e: any) { + updated[i] = { ...updated[i], status: 'error', error: e?.message ?? 'Fehler' }; + } + setUploads([...updated]); + } + + // Sound-Liste aktualisieren + setRefreshKey(k => k + 1); + void loadAnalytics(); + + // Auto-Dismiss nach 3s + uploadDismissRef.current = setTimeout(() => { + setShowUploads(false); + setUploads([]); + }, 3500); + } + + async function handleStop() { + if (!selected) return; + setLastPlayed(''); + try { await fetch(`/api/stop?guildId=${encodeURIComponent(guildId)}`, { method: 'POST' }); } catch { } + } + + async function handleRandom() { + if (!displaySounds.length || !selected) return; + const rnd = displaySounds[Math.floor(Math.random() * displaySounds.length)]; + handlePlay(rnd); + } + + async function toggleParty() { + if (chaosMode) { + await handleStop(); + try { await partyStop(guildId); } catch { } + } else { + if (!selected) return notify('Bitte einen Channel auswählen', 'error'); + try { await partyStart(guildId, channelId); } catch { } + } + } + + async function handleChannelSelect(ch: VoiceChannelInfo) { + const v = `${ch.guildId}:${ch.channelId}`; + setSelected(v); + setChannelOpen(false); + try { await setSelectedChannel(ch.guildId, ch.channelId); } catch { } + } + + function toggleFav(key: string) { + setFavs(prev => ({ ...prev, [key]: !prev[key] })); + } + + async function loadAdminSounds() { + setAdminLoading(true); + try { + const data = await fetchSounds('', '__all__', undefined, false); + setAdminSounds(data.items || []); + } catch (e: any) { + notify(e?.message || 'Admin-Sounds konnten nicht geladen werden', 'error'); + } finally { + setAdminLoading(false); + } + } + + function toggleAdminSelection(path: string) { + setAdminSelection(prev => ({ ...prev, [path]: !prev[path] })); + } + + function startRename(sound: Sound) { + setRenameTarget(soundKey(sound)); + setRenameValue(sound.name); + } + + function cancelRename() { + setRenameTarget(''); + setRenameValue(''); + } + + async function submitRename() { + if (!renameTarget) return; + const baseName = renameValue.trim().replace(/\.(mp3|wav)$/i, ''); + if (!baseName) { + notify('Bitte einen gültigen Namen eingeben', 'error'); + return; + } + try { + await adminRename(renameTarget, baseName); + notify('Sound umbenannt'); + cancelRename(); + setRefreshKey(k => k + 1); + if (showAdmin) await loadAdminSounds(); + } catch (e: any) { + notify(e?.message || 'Umbenennen fehlgeschlagen', 'error'); + } + } + + async function deleteAdminPaths(paths: string[]) { + if (paths.length === 0) return; + try { + await adminDelete(paths); + notify(paths.length === 1 ? 'Sound gelöscht' : `${paths.length} Sounds gelöscht`); + setAdminSelection({}); + cancelRename(); + setRefreshKey(k => k + 1); + if (showAdmin) await loadAdminSounds(); + } catch (e: any) { + notify(e?.message || 'Löschen fehlgeschlagen', 'error'); + } + } + + async function handleAdminLogin() { + try { + const ok = await adminLogin(adminPwd); + if (ok) { + setIsAdmin(true); + setAdminPwd(''); + notify('Admin eingeloggt'); + } + else notify('Falsches Passwort', 'error'); + } catch { notify('Login fehlgeschlagen', 'error'); } + } + + async function handleAdminLogout() { + try { + await adminLogout(); + setIsAdmin(false); + setAdminSelection({}); + cancelRename(); + notify('Ausgeloggt'); + } catch { } + } + + /* ── Computed ── */ + const displaySounds = useMemo(() => { + if (activeTab === 'favorites') return sounds.filter(s => favs[s.relativePath ?? s.fileName]); + return sounds; + }, [sounds, activeTab, favs]); + + const favCount = useMemo(() => Object.values(favs).filter(Boolean).length, [favs]); + + const visibleFolders = useMemo(() => + folders.filter(f => !['__all__', '__recent__', '__top3__'].includes(f.key)), + [folders]); + + const folderColorMap = useMemo(() => { + const m: Record = {}; + visibleFolders.forEach((f, i) => { m[f.key] = CAT_PALETTE[i % CAT_PALETTE.length]; }); + return m; + }, [visibleFolders]); + + const firstOfInitial = useMemo(() => { + const seen = new Set(); + const result = new Set(); + displaySounds.forEach((s, idx) => { + const ch = s.name.charAt(0).toUpperCase(); + if (!seen.has(ch)) { seen.add(ch); result.add(idx); } + }); + return result; + }, [displaySounds]); + + const channelsByGuild = useMemo(() => { + const groups: Record = {}; + channels.forEach(c => { + if (!groups[c.guildName]) groups[c.guildName] = []; + groups[c.guildName].push(c); + }); + return groups; + }, [channels]); + + const adminFilteredSounds = useMemo(() => { + const q = adminQuery.trim().toLowerCase(); + if (!q) return adminSounds; + return adminSounds.filter(s => { + const key = soundKey(s).toLowerCase(); + return s.name.toLowerCase().includes(q) + || (s.folder || '').toLowerCase().includes(q) + || key.includes(q); + }); + }, [adminQuery, adminSounds, soundKey]); + + const selectedAdminPaths = useMemo(() => + Object.keys(adminSelection).filter(k => adminSelection[k]), + [adminSelection]); + + const selectedVisibleCount = useMemo(() => + adminFilteredSounds.filter(s => !!adminSelection[soundKey(s)]).length, + [adminFilteredSounds, adminSelection, soundKey]); + + const allVisibleSelected = adminFilteredSounds.length > 0 && selectedVisibleCount === adminFilteredSounds.length; + const analyticsTop = analytics.mostPlayed.slice(0, 10); + const totalSoundsDisplay = analytics.totalSounds || total; + + const clockMain = clock.slice(0, 5); + const clockSec = clock.slice(5); + + /* ════════════════════════════════════════════ + RENDER + ════════════════════════════════════════════ */ + return ( +
+ {chaosMode &&
} + + {/* ═══ TOPBAR ═══ */} +
+
+
+ music_note +
+ Soundboard + + {/* Channel Dropdown */} +
e.stopPropagation()}> + + {channelOpen && ( +
+ {Object.entries(channelsByGuild).map(([guild, chs]) => ( + +
{guild}
+ {chs.map(ch => ( +
handleChannelSelect(ch)} + > + volume_up + {ch.channelName} +
+ ))} +
+ ))} + {channels.length === 0 && ( +
+ Keine Channels verfügbar +
+ )} +
+ )} +
+
+ +
+
{clockMain}{clockSec}
+
+ +
+ {lastPlayed && ( +
+
+
+
+
+ Last Played: {lastPlayed} +
+ )} + {selected && ( +
setShowConnModal(true)} style={{cursor:'pointer'}} title="Verbindungsdetails"> + + Verbunden + {voiceStats?.voicePing != null && ( + {voiceStats.voicePing}ms + )} +
+ )} + +
+
+ + {/* ═══ TOOLBAR ═══ */} +
+
+ + + +
+ +
+ search + setQuery(e.target.value)} + /> + {query && ( + + )} +
+ +
+ link + setImportUrl(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') void handleUrlImport(); }} + /> + +
+ +
+ +
+ { + const newVol = volume > 0 ? 0 : 0.5; + setVolume(newVol); + if (guildId) setVolumeLive(guildId, newVol).catch(() => {}); + }} + > + {volume === 0 ? 'volume_off' : volume < 0.5 ? 'volume_down' : 'volume_up'} + + { + const v = parseFloat(e.target.value); + setVolume(v); + if (guildId) { + if (volDebounceRef.current) clearTimeout(volDebounceRef.current); + volDebounceRef.current = setTimeout(() => { + setVolumeLive(guildId, v).catch(() => {}); + }, 120); + } + }} + style={{ '--vol': `${Math.round(volume * 100)}%` } as React.CSSProperties} + /> + {Math.round(volume * 100)}% +
+ + + + + + + +
+ grid_view + setCardSize(parseInt(e.target.value))} + /> +
+ +
+ {THEMES.map(t => ( +
setTheme(t.id)} + /> + ))} +
+
+ +
+
+ library_music +
+ Sounds gesamt + {totalSoundsDisplay} +
+
+ +
+ leaderboard +
+ Most Played +
+ {analyticsTop.length === 0 ? ( + Noch keine Plays + ) : ( + analyticsTop.map((item, idx) => ( + + {idx + 1}. {item.name} ({item.count}) + + )) + )} +
+
+
+
+ + {/* ═══ FOLDER CHIPS ═══ */} + {activeTab === 'all' && visibleFolders.length > 0 && ( +
+ {visibleFolders.map(f => { + const color = folderColorMap[f.key] || '#888'; + const isActive = activeFolder === f.key; + return ( + + ); + })} +
+ )} + + {/* ═══ MAIN ═══ */} +
+ {displaySounds.length === 0 ? ( +
+
{activeTab === 'favorites' ? '⭐' : '🔇'}
+
+ {activeTab === 'favorites' + ? 'Noch keine Favoriten' + : query + ? `Kein Sound für "${query}" gefunden` + : 'Keine Sounds vorhanden'} +
+
+ {activeTab === 'favorites' + ? 'Klick den Stern auf einem Sound!' + : 'Hier gibt\'s noch nichts zu hören.'} +
+
+ ) : ( +
+ {displaySounds.map((s, idx) => { + const key = s.relativePath ?? s.fileName; + const isFav = !!favs[key]; + const isPlaying = lastPlayed === s.name; + const isNew = s.isRecent || s.badges?.includes('new'); + const initial = s.name.charAt(0).toUpperCase(); + const showInitial = firstOfInitial.has(idx); + const folderColor = s.folder ? (folderColorMap[s.folder] || 'var(--accent)') : 'var(--accent)'; + + return ( +
{ + const card = e.currentTarget; + const rect = card.getBoundingClientRect(); + const ripple = document.createElement('div'); + ripple.className = 'ripple'; + const sz = Math.max(rect.width, rect.height); + ripple.style.width = ripple.style.height = sz + 'px'; + ripple.style.left = (e.clientX - rect.left - sz / 2) + 'px'; + ripple.style.top = (e.clientY - rect.top - sz / 2) + 'px'; + card.appendChild(ripple); + setTimeout(() => ripple.remove(), 500); + handlePlay(s); + }} + onContextMenu={e => { + e.preventDefault(); + e.stopPropagation(); + setCtxMenu({ + x: Math.min(e.clientX, window.innerWidth - 170), + y: Math.min(e.clientY, window.innerHeight - 140), + sound: s, + }); + }} + title={`${s.name}${s.folder ? ` (${s.folder})` : ''}`} + > + {isNew && NEU} + { e.stopPropagation(); toggleFav(key); }} + > + {isFav ? 'star' : 'star_border'} + + {showInitial && {initial}} + {s.name} + {s.folder && {s.folder}} +
+
+
+
+
+ ); + })} +
+ )} +
+ + {/* ═══ CONTEXT MENU ═══ */} + {ctxMenu && ( +
e.stopPropagation()} + > +
{ handlePlay(ctxMenu.sound); setCtxMenu(null); }}> + play_arrow + Abspielen +
+
{ + toggleFav(ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName); + setCtxMenu(null); + }}> + + {favs[ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName] ? 'star' : 'star_border'} + + Favorit +
+ {isAdmin && ( + <> +
+
{ + const path = ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName; + await deleteAdminPaths([path]); + setCtxMenu(null); + }}> + delete + Löschen +
+ + )} +
+ )} + + {/* ═══ CONNECTION MODAL ═══ */} + {showConnModal && voiceStats && (() => { + const uptimeSec = voiceStats.connectedSince + ? Math.floor((Date.now() - new Date(voiceStats.connectedSince).getTime()) / 1000) + : 0; + const h = Math.floor(uptimeSec / 3600); + const m = Math.floor((uptimeSec % 3600) / 60); + const s = uptimeSec % 60; + const uptimeStr = h > 0 + ? `${h}h ${String(m).padStart(2,'0')}m ${String(s).padStart(2,'0')}s` + : m > 0 + ? `${m}m ${String(s).padStart(2,'0')}s` + : `${s}s`; + const pingColor = (ms: number | null) => + ms == null ? 'var(--muted)' : ms < 80 ? 'var(--green)' : ms < 150 ? '#f0a830' : '#e04040'; + return ( +
setShowConnModal(false)}> +
e.stopPropagation()}> +
+ cell_tower + Verbindungsdetails + +
+
+
+ Voice Ping + + + {voiceStats.voicePing != null ? `${voiceStats.voicePing} ms` : '---'} + +
+
+ Gateway Ping + + + {voiceStats.gatewayPing >= 0 ? `${voiceStats.gatewayPing} ms` : '---'} + +
+
+ Status + + {voiceStats.status === 'ready' ? 'Verbunden' : voiceStats.status} + +
+
+ Kanal + {voiceStats.channelName || '---'} +
+
+ Verbunden seit + {uptimeStr} +
+
+
+
+ ); + })()} + + {/* ═══ TOAST ═══ */} + {notification && ( +
+ + {notification.type === 'error' ? 'error_outline' : 'check_circle'} + + {notification.msg} +
+ )} + + {/* ═══ ADMIN PANEL ═══ */} + {showAdmin && ( +
{ if (e.target === e.currentTarget) setShowAdmin(false); }}> +
+

+ Admin + +

+ {!isAdmin ? ( +
+
+ + setAdminPwd(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleAdminLogin()} + placeholder="Admin-Passwort..." + /> +
+ +
+ ) : ( +
+
+

Eingeloggt als Admin

+
+ + +
+
+ +
+ + setAdminQuery(e.target.value)} + placeholder="Nach Name, Ordner oder Pfad filtern..." + /> +
+ +
+ + + +
+ +
+ {adminLoading ? ( +
Lade Sounds...
+ ) : adminFilteredSounds.length === 0 ? ( +
Keine Sounds gefunden.
+ ) : ( +
+ {adminFilteredSounds.map(sound => { + const key = soundKey(sound); + const editing = renameTarget === key; + return ( +
+ + +
+
{sound.name}
+
+ {sound.folder ? `Ordner: ${sound.folder}` : 'Root'} + {' · '} + {key} +
+ {editing && ( +
+ setRenameValue(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') void submitRename(); + if (e.key === 'Escape') cancelRename(); + }} + placeholder="Neuer Name..." + /> + + +
+ )} +
+ + {!editing && ( +
+ + +
+ )} +
+ ); + })} +
+ )} +
+
+ )} +
+
+ )} + + {/* ── Drag & Drop Overlay ── */} + {isDragging && ( +
+
+ cloud_upload +
MP3 & WAV hier ablegen
+
Mehrere Dateien gleichzeitig möglich
+
+
+ )} + + {/* ── Upload-Queue ── */} + {showUploads && uploads.length > 0 && ( +
+
+ upload + + {uploads.every(u => u.status === 'done' || u.status === 'error') + ? `${uploads.filter(u => u.status === 'done').length} von ${uploads.length} hochgeladen` + : `Lade hoch… (${uploads.filter(u => u.status === 'done').length}/${uploads.length})`} + + +
+
+ {uploads.map(u => ( +
+ audio_file +
+
+ {u.savedName ?? u.file.name} +
+
{(u.file.size / 1024).toFixed(0)} KB
+
+ {(u.status === 'waiting' || u.status === 'uploading') && ( +
+
+
+ )} + + {u.status === 'done' ? 'check_circle' : + u.status === 'error' ? 'error' : + u.status === 'uploading' ? 'sync' : 'schedule'} + + {u.status === 'error' &&
{u.error}
} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/web/src/styles.css b/web/src/styles.css index 4fa09d6..d0e8cc8 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -353,6 +353,90 @@ input, select { 50% { box-shadow: 0 0 12px rgba(35, 165, 90, .8); } } +.conn-ping { + font-size: 10px; + opacity: .7; + margin-left: 2px; +} + +/* ── Connection Details Modal ── */ +.conn-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, .55); + z-index: 9000; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); + animation: fadeIn .15s ease; +} +.conn-modal { + background: var(--card); + border: 1px solid var(--border); + border-radius: 16px; + width: 340px; + box-shadow: 0 20px 60px rgba(0,0,0,.4); + overflow: hidden; + animation: slideUp .2s ease; +} +@keyframes slideUp { + from { transform: translateY(20px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} +.conn-modal-header { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 16px; + border-bottom: 1px solid var(--border); + font-weight: 700; + font-size: 14px; +} +.conn-modal-close { + margin-left: auto; + background: none; + border: none; + color: var(--muted); + cursor: pointer; + padding: 4px; + border-radius: 6px; + display: flex; + transition: all .15s; +} +.conn-modal-close:hover { + background: rgba(255,255,255,.08); + color: var(--fg); +} +.conn-modal-body { + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} +.conn-stat { + display: flex; + justify-content: space-between; + align-items: center; +} +.conn-stat-label { + color: var(--muted); + font-size: 13px; +} +.conn-stat-value { + font-weight: 600; + font-size: 13px; + display: flex; + align-items: center; + gap: 6px; +} +.conn-ping-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + /* ── Admin Icon Button ── */ .admin-btn-icon { width: 32px; From 149b97e5111e00be231f5fbf1e4f08e4512e4845 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 5 Mar 2026 16:33:28 +0100 Subject: [PATCH 20/35] rename: Soundboard -> Jukebox420 --- web/index.html | 2 +- web/src/App.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/index.html b/web/index.html index 34f6580..309b1b6 100644 --- a/web/index.html +++ b/web/index.html @@ -5,7 +5,7 @@ - Jukebox + Jukebox420 diff --git a/web/src/App.tsx b/web/src/App.tsx index 98ad596..77f8e1d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -600,7 +600,7 @@ export default function App() {
music_note
- Soundboard + Jukebox420 {/* Channel Dropdown */}
e.stopPropagation()}> From f3b858452e8935971b6e5c8cca967f82e303178b Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 5 Mar 2026 16:34:44 +0100 Subject: [PATCH 21/35] perf: Kaniko layer caching + CI version bump to 2.0.0 - --cache=true: Docker-Layer werden zwischen Builds gecached - --cache-repo: Cache-Layers in der Registry gespeichert - --snapshot-mode=redo: schnellere Snapshot-Erstellung - --compressed-caching=false: weniger CPU fuer Cache-Kompression - Runner: 8 CPUs + 8GB RAM + 256MB SHM zugewiesen - Version strings auf 2.0.0 aktualisiert --- .gitlab-ci.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5a4aa47..365e3c6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -30,16 +30,16 @@ docker-build: - | if [ "$CI_COMMIT_REF_NAME" = "main" ]; then TAG="main" - VERSION="1.1.0" + VERSION="2.0.0" CHANNEL="stable" elif [ "$CI_COMMIT_REF_NAME" = "feature/nightly" ] || [ "$CI_COMMIT_REF_NAME" = "nightly" ]; then TAG="nightly" - VERSION="1.1.0-nightly" + VERSION="2.0.0-nightly" CHANNEL="nightly" else CLEAN_TAG=$(echo "$CI_COMMIT_REF_NAME" | sed 's/\//-/g') TAG="$CLEAN_TAG" - VERSION="1.1.0-dev" + VERSION="2.0.0-dev" CHANNEL="dev" fi @@ -56,4 +56,8 @@ docker-build: --dockerfile "$CI_PROJECT_DIR/Dockerfile" \ --build-arg "VITE_BUILD_CHANNEL=$CHANNEL" \ --build-arg "VITE_APP_VERSION=$VERSION" \ + --cache=true \ + --cache-repo="$IMAGE_NAME/cache" \ + --snapshot-mode=redo \ + --compressed-caching=false \ $DESTINATIONS From 8598fe46aa00de086d95f9d9199786bc1dcee46f Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 5 Mar 2026 16:42:58 +0100 Subject: [PATCH 22/35] fix: Last Played Bubble dynamische Breite, voller Dateiname sichtbar - max-width entfernt (desktop + mobile) - overflow:hidden + text-overflow:ellipsis entfernt - Bubble waechst dynamisch mit Dateinamen-Laenge --- web/src/styles.css | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/web/src/styles.css b/web/src/styles.css index d0e8cc8..d40decd 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -106,7 +106,6 @@ html, body { height: 100%; - overflow: hidden; background: var(--bg-deep); color: var(--text-normal); font-family: var(--font); @@ -377,7 +376,6 @@ input, select { border-radius: 16px; width: 340px; box-shadow: 0 20px 60px rgba(0,0,0,.4); - overflow: hidden; animation: slideUp .2s ease; } @keyframes slideUp { @@ -971,7 +969,6 @@ input, select { transition: all var(--transition); border: 2px solid transparent; user-select: none; - overflow: hidden; aspect-ratio: 1; opacity: 0; animation: card-enter 350ms ease-out forwards; @@ -1067,8 +1064,6 @@ input, select { color: var(--text-normal); z-index: 1; max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; padding: 0 4px; } @@ -1206,7 +1201,7 @@ input, select { border: 1px solid rgba(var(--accent-rgb, 88, 101, 242), .2); font-size: 12px; color: var(--text-muted); - max-width: 200px; + max-width: none; min-width: 0; animation: np-fade-in 300ms ease; } @@ -1219,8 +1214,6 @@ input, select { .np-name { color: var(--accent); font-weight: 600; - overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; } @@ -1696,8 +1689,6 @@ input, select { font-size: 14px; font-weight: 600; color: var(--text-normal); - overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; } @@ -1705,8 +1696,6 @@ input, select { margin-top: 3px; font-size: 11px; color: var(--text-faint); - overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; } @@ -1850,7 +1839,7 @@ input, select { } .now-playing { - max-width: 120px; + max-width: none; } .toolbar .tb-btn { @@ -1938,7 +1927,6 @@ input, select { border-radius: 14px; box-shadow: 0 8px 40px rgba(0, 0, 0, .45); z-index: 200; - overflow: hidden; animation: slide-up 200ms cubic-bezier(.16,1,.3,1); } @@ -2012,7 +2000,6 @@ input, select { font-weight: 500; color: var(--text-normal); white-space: nowrap; - overflow: hidden; text-overflow: ellipsis; } @@ -2027,7 +2014,6 @@ input, select { height: 3px; background: rgba(255, 255, 255, .07); border-radius: 2px; - overflow: hidden; margin-top: 4px; } @@ -2077,7 +2063,6 @@ input, select { height: 1px; padding: 0; margin: -1px; - overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0; From 852bda73f66df45afbc802c684a86a314e8b4ea7 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 5 Mar 2026 16:53:56 +0100 Subject: [PATCH 23/35] fix: remove slow Kaniko snapshot-mode=redo and compressed-caching flags These flags caused build time regression from ~2:48 to 6:30. snapshot-mode=redo does full filesystem walks per layer (slowest mode). Default time-based mode is much faster for CI builds. --- .gitlab-ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 365e3c6..6e0b0f9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -58,6 +58,4 @@ docker-build: --build-arg "VITE_APP_VERSION=$VERSION" \ --cache=true \ --cache-repo="$IMAGE_NAME/cache" \ - --snapshot-mode=redo \ - --compressed-caching=false \ $DESTINATIONS From 8d663f239286c9fbbb4db9ec73ef1ad498469631 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 5 Mar 2026 17:07:33 +0100 Subject: [PATCH 24/35] perf: route registry traffic through LAN instead of internet Use internal registry at 10.10.10.10:5050 instead of git.daddelolymp.de which routes through Pangolin proxy over internet (40Mbit bottleneck). All layer cache pulls/pushes and image pushes now stay on LAN. --- .gitlab-ci.yml | 124 +++++++++++++++++++++++++------------------------ 1 file changed, 63 insertions(+), 61 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6e0b0f9..d7d50ec 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,61 +1,63 @@ -stages: - - build - -variables: - IMAGE_NAME: "$CI_REGISTRY/$CI_PROJECT_PATH" - CI_SERVER_URL: "http://10.10.10.10:9080" - GITLAB_FEATURES: "" - -docker-build: - stage: build - image: - name: gcr.io/kaniko-project/executor:v1.23.2-debug - entrypoint: [""] - rules: - - if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH - before_script: - - mkdir -p /kaniko/.docker - - | - cat > /kaniko/.docker/config.json < /kaniko/.docker/config.json < Date: Thu, 5 Mar 2026 17:15:37 +0100 Subject: [PATCH 25/35] fix: use port 9080 for internal registry (nginx proxy) Port 5050 binds to 127.0.0.1 inside container, unreachable externally. Port 9080 (nginx) proxies /v2/ correctly and returns 401 as expected. Combined with network_mode=host runner, all traffic stays on LAN. --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d7d50ec..8da7939 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,7 +2,7 @@ stages: - build variables: - INTERNAL_REGISTRY: "10.10.10.10:5050" + INTERNAL_REGISTRY: "10.10.10.10:9080" IMAGE_NAME: "$INTERNAL_REGISTRY/$CI_PROJECT_PATH" CI_SERVER_URL: "http://10.10.10.10:9080" GITLAB_FEATURES: "" From 197af929093361cbd8b7043615ea089fdb9c1189 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 5 Mar 2026 21:12:20 +0100 Subject: [PATCH 26/35] fix: restore overflow on sound cards + fix connectedSince uptime - Restore overflow:hidden on html/body, sound-card, modal, admin items - Only .now-playing and .np-name keep unbounded width (Last Played bubble) - Fix Verbunden seit 0s: auto-set connectedSince when connection is ready but timestamp was not recorded (e.g. after redeploy) --- server/src/index.ts | 3286 ++++++++++++++++++++++--------------------- web/src/styles.css | 9 + 2 files changed, 1656 insertions(+), 1639 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 01c7060..c01a72a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,1639 +1,1647 @@ -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 cors from 'cors'; -import crypto from 'node:crypto'; -import { Client, GatewayIntentBits, Partials, ChannelType, Events, type Message, VoiceState } from 'discord.js'; -import { - joinVoiceChannel, - createAudioPlayer, - createAudioResource, - AudioPlayerStatus, - NoSubscriberBehavior, - getVoiceConnection, - type VoiceConnection, - type AudioResource, - StreamType, - generateDependencyReport, - entersState, - VoiceConnectionStatus -} from '@discordjs/voice'; -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, Readable } from 'node:stream'; - -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 ?? ''; -const ADMIN_PWD = process.env.ADMIN_PWD ?? ''; -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 }); - -// Persistenter Zustand: Lautstärke/Plays + Kategorien -type Category = { id: string; name: string; color?: string; sort?: number }; - type PersistedState = { - volumes: Record; - plays: Record; - totalPlays: number; - categories?: Category[]; - fileCategories?: Record; // relPath or fileName -> categoryIds[] - fileBadges?: Record; // relPath or fileName -> custom badges (emoji/text) - selectedChannels?: Record; // guildId -> channelId (serverweite Auswahl) - entranceSounds?: Record; // userId -> relativePath or fileName - exitSounds?: Record; // userId -> relativePath or fileName -}; -// 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'); - -function readPersistedState(): PersistedState { - try { - // 1) Bevorzugt neuen Speicherort lesen - if (fs.existsSync(STATE_FILE_NEW)) { - const raw = fs.readFileSync(STATE_FILE_NEW, 'utf8'); - const parsed = JSON.parse(raw); - return { - volumes: parsed.volumes ?? {}, - plays: parsed.plays ?? {}, - totalPlays: parsed.totalPlays ?? 0, - categories: Array.isArray(parsed.categories) ? parsed.categories : [], - fileCategories: parsed.fileCategories ?? {}, - fileBadges: parsed.fileBadges ?? {}, - selectedChannels: parsed.selectedChannels ?? {}, - entranceSounds: parsed.entranceSounds ?? {}, - exitSounds: parsed.exitSounds ?? {} - } as PersistedState; - } - // 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); - const migrated: PersistedState = { - volumes: parsed.volumes ?? {}, - plays: parsed.plays ?? {}, - totalPlays: parsed.totalPlays ?? 0, - categories: Array.isArray(parsed.categories) ? parsed.categories : [], - fileCategories: parsed.fileCategories ?? {}, - fileBadges: parsed.fileBadges ?? {}, - selectedChannels: parsed.selectedChannels ?? {}, - entranceSounds: parsed.entranceSounds ?? {}, - exitSounds: parsed.exitSounds ?? {} - }; - try { - fs.mkdirSync(path.dirname(STATE_FILE_NEW), { recursive: true }); - fs.writeFileSync(STATE_FILE_NEW, JSON.stringify(migrated, null, 2), 'utf8'); - } catch {} - return migrated; - } - } catch {} - return { volumes: {}, plays: {}, totalPlays: 0 }; -} - -function writePersistedState(state: PersistedState): void { - try { - fs.mkdirSync(path.dirname(STATE_FILE_NEW), { recursive: true }); - fs.writeFileSync(STATE_FILE_NEW, JSON.stringify(state, null, 2), 'utf8'); - } catch (e) { - console.warn('Persisted state konnte nicht geschrieben werden:', e); - } -} - -const persistedState: PersistedState = readPersistedState(); - -// Debounced write: sammelt Schreibaufrufe und schreibt max. alle 2 Sekunden -let _writeTimer: ReturnType | 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; - writePersistedStateDebounced(); // Debounced: Play-Counter sind nicht kritisch - } 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'); - -// 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 }); - -// In-Memory PCM Cache: gecachte PCM-Dateien werden beim ersten Abspielen in den RAM geladen. -// Danach kein Disk-I/O mehr bei Cache-Hits -> nahezu instant playback. -const pcmMemoryCache = new Map(); -const PCM_MEMORY_CACHE_MAX_MB = Number(process.env.PCM_CACHE_MAX_MB ?? '512'); -let pcmMemoryCacheBytes = 0; - -function getPcmFromMemory(cachedPath: string): Buffer | null { - const buf = pcmMemoryCache.get(cachedPath); - if (buf) return buf; - // Erste Anfrage: von Disk in RAM laden - try { - const data = fs.readFileSync(cachedPath); - const newTotal = pcmMemoryCacheBytes + data.byteLength; - if (newTotal <= PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) { - pcmMemoryCache.set(cachedPath, data); - pcmMemoryCacheBytes = newTotal; - } - return data; - } catch { return null; } -} - -function invalidatePcmMemory(cachedPath: string): void { - const buf = pcmMemoryCache.get(cachedPath); - if (buf) { - pcmMemoryCacheBytes -= buf.byteLength; - pcmMemoryCache.delete(cachedPath); - } -} - -/** 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 {} invalidatePcmMemory(cacheFile); return null; } - } catch { return null; } - return cacheFile; -} - -/** Normalisiert eine Audiodatei via ffmpeg loudnorm und speichert das Ergebnis im Cache */ -function normalizeToCache(filePath: string): Promise { - 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 { - if (!NORMALIZE_ENABLE) return; - const t0 = Date.now(); - const allSounds = listAllSounds(); - - // Set aller erwarteten Cache-Keys - const expectedKeys = new Set(); - 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 { - 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 -void nacl.randomBytes(1); -console.log(generateDependencyReport()); - -// --- Discord Client --- -const client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildVoiceStates, - GatewayIntentBits.DirectMessages, - GatewayIntentBits.MessageContent, - ], - partials: [Partials.Channel] -}); - -type GuildAudioState = { - connection: VoiceConnection; - player: ReturnType; - guildId: string; - channelId: string; - currentResource?: AudioResource; - currentVolume: number; // 0..1 -}; -const guildAudioState = new Map(); -// Partymode: serverseitige Steuerung (global pro Guild) -const partyTimers = new Map(); -const partyActive = new Set(); -// Now-Playing: aktuell gespielter Sound pro Guild -const nowPlaying = new Map(); -// Verbindungszeitpunkt pro Guild (fuer Uptime-Anzeige im Frontend) -const connectedSince = new Map(); -// SSE-Klienten für Broadcasts (z.B. Partymode Status) -const sseClients = new Set(); -function sseBroadcast(payload: any) { - const data = `data: ${JSON.stringify(payload)}\n\n`; - for (const res of sseClients) { - try { res.write(data); } catch {} - } -} - -// 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 }); -} - -async function playFilePath(guildId: string, channelId: string, filePath: string, volume?: number, relativeKey?: string): Promise { - 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); - } - // 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 - }); - // Reuse bestehenden Player falls vorhanden - 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 {} - - // 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); - } - const useVolume = typeof volume === 'number' && Number.isFinite(volume) - ? Math.max(0, Math.min(1, volume)) - : (state.currentVolume ?? 1); - let resource: AudioResource; - if (NORMALIZE_ENABLE) { - const cachedPath = getNormCachePath(filePath); - if (cachedPath) { - // Cache-Hit: PCM aus RAM lesen (kein Disk-I/O, instant) - const pcmBuf = getPcmFromMemory(cachedPath); - if (pcmBuf) { - const useInline = useVolume !== 1; - resource = createAudioResource(Readable.from(pcmBuf), { - inlineVolume: useInline, - inputType: StreamType.Raw - }); - } else { - // Fallback: Stream von Disk - const pcmStream = fs.createReadStream(cachedPath, { highWaterMark: 256 * 1024 }); - 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)}`); - // In Memory-Cache laden fuer naechsten Aufruf - try { - const buf = fs.readFileSync(cacheFile); - if (pcmMemoryCacheBytes + buf.byteLength <= PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) { - pcmMemoryCache.set(cacheFile, buf); - pcmMemoryCacheBytes += buf.byteLength; - } - } catch {} - }); - 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 }); - } - if (resource.volume) resource.volume.setVolume(useVolume); - state.player.stop(); - 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); -} - -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' + - '?list - listet alle Audio-Dateien (mp3/wav)\n' + - '?entrance | remove - setze oder entferne deinen Entrance-Sound\n' + - '?exit | remove - setze oder entferne deinen Exit-Sound\n' - ); - return; - } - if (cmd === '?list') { - const files = fs - .readdirSync(SOUNDS_DIR) - .filter(f => { const l = f.toLowerCase(); return l.endsWith('.mp3') || l.endsWith('.wav'); }); - await reply(files.length ? files.join('\n') : 'Keine Dateien gefunden.'); - return; - } - if (cmd === '?entrance') { - 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 | 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; - } - 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; } - 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 {} - await reply(`Entrance-Sound gesetzt: ${resolve}`); return; - } - if (cmd === '?exit') { - 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 | 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; - } - 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; } - 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 {} - await reply(`Exit-Sound gesetzt: ${resolve}`); return; - } - await reply('Unbekannter Command. Nutze ?help.'); -} - -async function ensureConnectionReady(connection: VoiceConnection, channelId: string, guildId: string, guild: any): Promise { - // Versuch 1: Warten ob bestehende Connection ready wird - 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...`); - } - - // Versuch 2: Rejoin - 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.warn(`${new Date().toISOString()} | VoiceConnection still not ready after rejoin`); - } - - // Versuch 3: Komplett neu verbinden - try { connection.destroy(); } catch {} - guildAudioState.delete(guildId); - - const newConn = joinVoiceChannel({ - channelId, - guildId, - adapterCreator: guild.voiceAdapterCreator as any, - selfMute: false, - selfDeaf: false - }); - try { - await entersState(newConn, VoiceConnectionStatus.Ready, 15_000); - console.log(`${new Date().toISOString()} | VoiceConnection ready after fresh join`); - return newConn; - } catch (e3) { - console.error(`${new Date().toISOString()} | VoiceConnection failed after all attempts, cleaning up`); - try { newConn.destroy(); } catch {} - guildAudioState.delete(guildId); - throw new Error('Voice connection failed after 3 attempts'); - } -} - -function attachVoiceLifecycle(state: GuildAudioState, guild: any) { - const { connection } = state; - // Mehrfach-Registrierung verhindern - if ((connection as any).__lifecycleAttached) return; - try { (connection as any).setMaxListeners?.(0); } catch {} - - // Retry-Tracking um Endlosschleife zu verhindern - let reconnectAttempts = 0; - const MAX_RECONNECT_ATTEMPTS = 3; - let isReconnecting = false; - - connection.on('stateChange', async (oldS: any, newS: any) => { - console.log(`${new Date().toISOString()} | VoiceConnection: ${oldS.status} -> ${newS.status}`); - - // Ready zurückgesetzt -> Retry-Counter reset - if (newS.status === VoiceConnectionStatus.Ready) { - reconnectAttempts = 0; - isReconnecting = false; - if (!connectedSince.has(state.guildId)) connectedSince.set(state.guildId, new Date().toISOString()); - return; - } - - // Re-Entranz verhindern: wenn schon ein Reconnect läuft, nicht nochmal starten - if (isReconnecting) return; - - 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 { - if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { - reconnectAttempts++; - console.warn(`${new Date().toISOString()} | Disconnected rejoin attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`); - connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); - } else { - console.error(`${new Date().toISOString()} | Max reconnect attempts reached from Disconnected, destroying and rejoining fresh`); - reconnectAttempts = 0; - try { connection.destroy(); } catch {} - 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.Destroyed) { - connectedSince.delete(state.guildId); - // 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) { - isReconnecting = true; - try { - await entersState(connection, VoiceConnectionStatus.Ready, 15_000); - // Ready wird oben im Handler behandelt - } catch (e) { - reconnectAttempts++; - if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { - const backoffMs = reconnectAttempts * 2_000; - console.warn(`${new Date().toISOString()} | Voice not ready from ${newS.status}, attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}, backoff ${backoffMs}ms`); - await new Promise(r => setTimeout(r, backoffMs)); - isReconnecting = false; - connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); - } else { - console.error(`${new Date().toISOString()} | Max reconnect attempts reached, destroying and rejoining fresh`); - reconnectAttempts = 0; - isReconnecting = false; - try { connection.destroy(); } catch {} - 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); - } - } - } - } catch (e) { - isReconnecting = false; - console.error(`${new Date().toISOString()} | Voice lifecycle handler error`, e); - } - }); - (connection as any).__lifecycleAttached = true; -} - -client.once(Events.ClientReady, () => { - console.log(`Bot eingeloggt als ${client.user?.tag}`); -}); - -// Voice State Updates: Entrance/Exit -client.on(Events.VoiceStateUpdate, async (oldState: VoiceState, newState: VoiceState) => { - try { - const userId = (newState.id || oldState.id) as string; - if (!userId) return; - // Eigene Events ignorieren - if (userId === client.user?.id) return; - const guildId = (newState.guild?.id || oldState.guild?.id) as string; - if (!guildId) return; - - const before = oldState.channelId; - const after = newState.channelId; - console.log(`${new Date().toISOString()} | VoiceStateUpdate user=${userId} before=${before ?? '-'} after=${after ?? '-'}`); - - // 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}`); - 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)) { - try { - // Dem Channel beitreten und Sound spielen - await playFilePath(guildId, after, abs, undefined, rel); - console.log(`${new Date().toISOString()} | Entrance played for ${userId}: ${rel}`); - } catch (e) { console.warn('Entrance play error', e); } - } - } - } - // 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}`); - 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)) { - try { - await playFilePath(guildId, before, abs, undefined, rel); - console.log(`${new Date().toISOString()} | Exit played for ${userId}: ${rel}`); - } catch (e) { console.warn('Exit play error', e); } - } - } - } 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}`); - } - } catch (e) { - console.warn('VoiceStateUpdate entrance/exit handling error', e); - } -}); - -client.on(Events.MessageCreate, async (message: Message) => { - try { - if (message.author?.bot) return; - // Commands überall annehmen (inkl. DMs) - const content = (message.content || '').trim(); - if (content.startsWith('?')) { - await handleCommand(message, content); - return; - } - // Dateiuploads nur per DM - if (!message.channel?.isDMBased?.()) return; - if (message.attachments.size === 0) return; - - for (const [, attachment] of message.attachments) { - const name = attachment.name ?? 'upload'; - const lower = name.toLowerCase(); - if (!(lower.endsWith('.mp3') || lower.endsWith('.wav'))) continue; - - 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; - const ext = path.parse(safeName).ext || (lower.endsWith('.wav') ? '.wav' : '.mp3'); - 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)); - // 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) { - 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) => { - 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(); - 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 { - 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')) }); -}); - -app.get('/api/sounds', (req: Request, res: Response) => { - const q = String(req.query.q ?? '').toLowerCase(); - const folderFilter = typeof req.query.folder === 'string' ? (req.query.folder as string) : '__all__'; - const categoryFilter = typeof (req.query as any).categoryId === 'string' ? String((req.query as any).categoryId) : undefined; - const fuzzyParam = String((req.query as any).fuzzy ?? '0'); - const useFuzzy = fuzzyParam === '1' || fuzzyParam === 'true'; - - const allItems = listAllSounds(); - - // Ordner-Statistik aus allItems ableiten - const folderCounts = new Map(); - 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 }); - } - - // 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)); - let itemsByFolder = allItems; - if (folderFilter !== '__all__') { - 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)); - } - } - // 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) { - 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)); - } - } - - const total = allItems.length; - const recentCount = Math.min(10, total); - // 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 }>; - - const foldersOut = [ - { key: '__all__', name: 'Alle', count: total }, - { key: '__recent__', name: 'Neu', count: recentCount }, - ...(top3.length ? [{ key: '__top3__', name: 'Most Played (3)', count: top3.length }] : []), - ...folders - ]; - // isRecent-Flag für UI (Top 5 der neuesten) - // Kategorie-Filter (virtuell) anwenden, wenn gesetzt - let result = filteredItems; - if (categoryFilter) { - const fc = persistedState.fileCategories ?? {}; - result = result.filter((it) => { - const key = it.relativePath ?? it.fileName; - const cats = fc[key] ?? []; - return cats.includes(categoryFilter); - }); - } - if (folderFilter === '__top3__') { - const keys = new Set(top3.map(t => t.key.split(':')[1])); - result = allItems.filter(i => keys.has(i.relativePath ?? i.fileName)); - } - - // 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; - }); - - res.json({ items: withRecentFlag, total, folders: foldersOut, categories: persistedState.categories ?? [], fileCategories: persistedState.fileCategories ?? {} }); -}); - -// --- 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) { - 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' }); - } - } 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' }); - 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 = 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 ?? [] }); -}); - -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 }); -}); - -// 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 }); -}); - -// 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 }); -}); - -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); - const result: Array<{ guildId: string; guildName: string; channelId: string; channelName: string; selected?: boolean }> = []; - 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) { - const sel = getSelectedChannelForGuild(guild.id); - result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name, selected: sel === ch.id }); - } - } - } - result.sort((a, b) => a.guildName.localeCompare(b.guildName) || a.channelName.localeCompare(b.channelName)); - res.json(result); -}); - -// 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' }); - } -}); - -app.post('/api/play', async (req: Request, res: Response) => { - try { - const { soundName, guildId, channelId, volume, folder, relativePath } = req.body as { - soundName?: string; - guildId?: string; - channelId?: string; - volume?: number; // 0..1 - folder?: string; // optional subfolder key - relativePath?: string; // optional direct relative path - }; - if (!soundName || !guildId || !channelId) return res.status(400).json({ error: 'soundName, guildId, channelId erforderlich' }); - - let filePath: string; - if (relativePath) filePath = path.join(SOUNDS_DIR, relativePath); - 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; - } - if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Sound nicht gefunden' }); - - // 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); - return res.status(500).json({ error: err?.message ?? 'Unbekannter Fehler' }); - } -}); - -// 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) { - // Kein aktiver Player: nur persistieren für nächste Wiedergabe - persistedState.volumes[guildId] = safeVolume; - writePersistedState(persistedState); - // Broadcast neue Lautstärke an alle Clients - sseBroadcast({ type: 'volume', guildId, volume: safeVolume }); - return res.json({ ok: true, volume: safeVolume, persistedOnly: true }); - } - state.currentVolume = safeVolume; - if (state.currentResource?.volume) { - state.currentResource.volume.setVolume(safeVolume); - console.log(`${new Date().toISOString()} | live setVolume(${safeVolume}) guild=${guildId}`); - } - persistedState.volumes[guildId] = safeVolume; - writePersistedState(persistedState); - // Broadcast neue Lautstärke an alle Clients - sseBroadcast({ type: 'volume', guildId, volume: safeVolume }); - 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' }); - } -}); - -// 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 }); -}); - -// 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); - // 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); - if (t) clearTimeout(t); - partyTimers.delete(guildId); - partyActive.delete(guildId); - sseBroadcast({ type: 'party', guildId, active: false }); - } 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(); - // Broadcast Status - sseBroadcast({ type: 'party', guildId, active: true, channelId }); -} - -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); - sseBroadcast({ type: 'party', guildId: id, active: false }); - return res.json({ ok: true }); - } catch (e: any) { - console.error('party/stop error', e); - return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' }); - } -}); - -// 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 - try { - const statsSnap: Record = {}; - for (const [gId, st] of guildAudioState) { - const ch = client.channels.cache.get(st.channelId); - statsSnap[gId] = { - voicePing: (st.connection.ping as any)?.ws ?? null, - gatewayPing: client.ws.ping, - status: st.connection.state?.status ?? 'unknown', - channelName: ch && 'name' in ch ? (ch as any).name : null, - connectedSince: connectedSince.get(gId) ?? null, - }; - } - res.write(`data: ${JSON.stringify({ type: 'snapshot', party: Array.from(partyActive), selected: persistedState.selectedChannels ?? {}, volumes: persistedState.volumes ?? {}, nowplaying: Object.fromEntries(nowPlaying), voicestats: statsSnap })}\n\n`); - } catch {} - - // Ping, damit Proxies die Verbindung offen halten - const ping = setInterval(() => { try { res.write(':\n\n'); } catch {} }, 15_000); - - sseClients.add(res); - req.on('close', () => { - sseClients.delete(res); - clearInterval(ping); - try { res.end(); } catch {} - }); -}); - -// --- 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)) { - app.use(express.static(webDistPath)); - app.get('/{*splat}', (_req, res) => { - res.sendFile(path.join(webDistPath, 'index.html')); - }); -} - -// Node 24 warnt bei negativen Timeout-Werten (aus @discordjs/voice intern) - harmlos unterdruecken -process.on('warning', (warning) => { - if (warning.name === 'TimeoutNegativeWarning') return; - console.warn(warning.name + ': ' + warning.message); -}); -process.on('uncaughtException', (err) => { - console.error(`FATAL uncaughtException:`, err); - process.exit(1); -}); -process.on('unhandledRejection', (reason) => { - console.error(`FATAL unhandledRejection:`, reason); -}); - -app.listen(PORT, () => { - console.log(`Server läuft auf http://0.0.0.0:${PORT}`); - - // Vollständige Cache-Synchronisation beim Start (Hintergrund) - syncNormCache(); - - // Voice-Stats alle 5 Sekunden an alle SSE-Clients broadcasten - setInterval(() => { - if (sseClients.size === 0 || guildAudioState.size === 0) return; - for (const [gId, st] of guildAudioState) { - const ch = client.channels.cache.get(st.channelId); - sseBroadcast({ - type: 'voicestats', - guildId: gId, - voicePing: (st.connection.ping as any)?.ws ?? null, - gatewayPing: client.ws.ping, - status: st.connection.state?.status ?? 'unknown', - channelName: ch && 'name' in ch ? (ch as any).name : null, - connectedSince: connectedSince.get(gId) ?? null, - }); - } - }, 5_000); -}); - - - - +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 cors from 'cors'; +import crypto from 'node:crypto'; +import { Client, GatewayIntentBits, Partials, ChannelType, Events, type Message, VoiceState } from 'discord.js'; +import { + joinVoiceChannel, + createAudioPlayer, + createAudioResource, + AudioPlayerStatus, + NoSubscriberBehavior, + getVoiceConnection, + type VoiceConnection, + type AudioResource, + StreamType, + generateDependencyReport, + entersState, + VoiceConnectionStatus +} from '@discordjs/voice'; +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, Readable } from 'node:stream'; + +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 ?? ''; +const ADMIN_PWD = process.env.ADMIN_PWD ?? ''; +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 }); + +// Persistenter Zustand: Lautstärke/Plays + Kategorien +type Category = { id: string; name: string; color?: string; sort?: number }; + type PersistedState = { + volumes: Record; + plays: Record; + totalPlays: number; + categories?: Category[]; + fileCategories?: Record; // relPath or fileName -> categoryIds[] + fileBadges?: Record; // relPath or fileName -> custom badges (emoji/text) + selectedChannels?: Record; // guildId -> channelId (serverweite Auswahl) + entranceSounds?: Record; // userId -> relativePath or fileName + exitSounds?: Record; // userId -> relativePath or fileName +}; +// 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'); + +function readPersistedState(): PersistedState { + try { + // 1) Bevorzugt neuen Speicherort lesen + if (fs.existsSync(STATE_FILE_NEW)) { + const raw = fs.readFileSync(STATE_FILE_NEW, 'utf8'); + const parsed = JSON.parse(raw); + return { + volumes: parsed.volumes ?? {}, + plays: parsed.plays ?? {}, + totalPlays: parsed.totalPlays ?? 0, + categories: Array.isArray(parsed.categories) ? parsed.categories : [], + fileCategories: parsed.fileCategories ?? {}, + fileBadges: parsed.fileBadges ?? {}, + selectedChannels: parsed.selectedChannels ?? {}, + entranceSounds: parsed.entranceSounds ?? {}, + exitSounds: parsed.exitSounds ?? {} + } as PersistedState; + } + // 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); + const migrated: PersistedState = { + volumes: parsed.volumes ?? {}, + plays: parsed.plays ?? {}, + totalPlays: parsed.totalPlays ?? 0, + categories: Array.isArray(parsed.categories) ? parsed.categories : [], + fileCategories: parsed.fileCategories ?? {}, + fileBadges: parsed.fileBadges ?? {}, + selectedChannels: parsed.selectedChannels ?? {}, + entranceSounds: parsed.entranceSounds ?? {}, + exitSounds: parsed.exitSounds ?? {} + }; + try { + fs.mkdirSync(path.dirname(STATE_FILE_NEW), { recursive: true }); + fs.writeFileSync(STATE_FILE_NEW, JSON.stringify(migrated, null, 2), 'utf8'); + } catch {} + return migrated; + } + } catch {} + return { volumes: {}, plays: {}, totalPlays: 0 }; +} + +function writePersistedState(state: PersistedState): void { + try { + fs.mkdirSync(path.dirname(STATE_FILE_NEW), { recursive: true }); + fs.writeFileSync(STATE_FILE_NEW, JSON.stringify(state, null, 2), 'utf8'); + } catch (e) { + console.warn('Persisted state konnte nicht geschrieben werden:', e); + } +} + +const persistedState: PersistedState = readPersistedState(); + +// Debounced write: sammelt Schreibaufrufe und schreibt max. alle 2 Sekunden +let _writeTimer: ReturnType | 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; + writePersistedStateDebounced(); // Debounced: Play-Counter sind nicht kritisch + } 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'); + +// 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 }); + +// In-Memory PCM Cache: gecachte PCM-Dateien werden beim ersten Abspielen in den RAM geladen. +// Danach kein Disk-I/O mehr bei Cache-Hits -> nahezu instant playback. +const pcmMemoryCache = new Map(); +const PCM_MEMORY_CACHE_MAX_MB = Number(process.env.PCM_CACHE_MAX_MB ?? '512'); +let pcmMemoryCacheBytes = 0; + +function getPcmFromMemory(cachedPath: string): Buffer | null { + const buf = pcmMemoryCache.get(cachedPath); + if (buf) return buf; + // Erste Anfrage: von Disk in RAM laden + try { + const data = fs.readFileSync(cachedPath); + const newTotal = pcmMemoryCacheBytes + data.byteLength; + if (newTotal <= PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) { + pcmMemoryCache.set(cachedPath, data); + pcmMemoryCacheBytes = newTotal; + } + return data; + } catch { return null; } +} + +function invalidatePcmMemory(cachedPath: string): void { + const buf = pcmMemoryCache.get(cachedPath); + if (buf) { + pcmMemoryCacheBytes -= buf.byteLength; + pcmMemoryCache.delete(cachedPath); + } +} + +/** 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 {} invalidatePcmMemory(cacheFile); return null; } + } catch { return null; } + return cacheFile; +} + +/** Normalisiert eine Audiodatei via ffmpeg loudnorm und speichert das Ergebnis im Cache */ +function normalizeToCache(filePath: string): Promise { + 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 { + if (!NORMALIZE_ENABLE) return; + const t0 = Date.now(); + const allSounds = listAllSounds(); + + // Set aller erwarteten Cache-Keys + const expectedKeys = new Set(); + 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 { + 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 +void nacl.randomBytes(1); +console.log(generateDependencyReport()); + +// --- Discord Client --- +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.DirectMessages, + GatewayIntentBits.MessageContent, + ], + partials: [Partials.Channel] +}); + +type GuildAudioState = { + connection: VoiceConnection; + player: ReturnType; + guildId: string; + channelId: string; + currentResource?: AudioResource; + currentVolume: number; // 0..1 +}; +const guildAudioState = new Map(); +// Partymode: serverseitige Steuerung (global pro Guild) +const partyTimers = new Map(); +const partyActive = new Set(); +// Now-Playing: aktuell gespielter Sound pro Guild +const nowPlaying = new Map(); +// Verbindungszeitpunkt pro Guild (fuer Uptime-Anzeige im Frontend) +const connectedSince = new Map(); +// SSE-Klienten für Broadcasts (z.B. Partymode Status) +const sseClients = new Set(); +function sseBroadcast(payload: any) { + const data = `data: ${JSON.stringify(payload)}\n\n`; + for (const res of sseClients) { + try { res.write(data); } catch {} + } +} + +// 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 }); +} + +async function playFilePath(guildId: string, channelId: string, filePath: string, volume?: number, relativeKey?: string): Promise { + 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); + } + // 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 + }); + // Reuse bestehenden Player falls vorhanden + 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 {} + + // 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); + } + const useVolume = typeof volume === 'number' && Number.isFinite(volume) + ? Math.max(0, Math.min(1, volume)) + : (state.currentVolume ?? 1); + let resource: AudioResource; + if (NORMALIZE_ENABLE) { + const cachedPath = getNormCachePath(filePath); + if (cachedPath) { + // Cache-Hit: PCM aus RAM lesen (kein Disk-I/O, instant) + const pcmBuf = getPcmFromMemory(cachedPath); + if (pcmBuf) { + const useInline = useVolume !== 1; + resource = createAudioResource(Readable.from(pcmBuf), { + inlineVolume: useInline, + inputType: StreamType.Raw + }); + } else { + // Fallback: Stream von Disk + const pcmStream = fs.createReadStream(cachedPath, { highWaterMark: 256 * 1024 }); + 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)}`); + // In Memory-Cache laden fuer naechsten Aufruf + try { + const buf = fs.readFileSync(cacheFile); + if (pcmMemoryCacheBytes + buf.byteLength <= PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) { + pcmMemoryCache.set(cacheFile, buf); + pcmMemoryCacheBytes += buf.byteLength; + } + } catch {} + }); + 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 }); + } + if (resource.volume) resource.volume.setVolume(useVolume); + state.player.stop(); + 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); +} + +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' + + '?list - listet alle Audio-Dateien (mp3/wav)\n' + + '?entrance | remove - setze oder entferne deinen Entrance-Sound\n' + + '?exit | remove - setze oder entferne deinen Exit-Sound\n' + ); + return; + } + if (cmd === '?list') { + const files = fs + .readdirSync(SOUNDS_DIR) + .filter(f => { const l = f.toLowerCase(); return l.endsWith('.mp3') || l.endsWith('.wav'); }); + await reply(files.length ? files.join('\n') : 'Keine Dateien gefunden.'); + return; + } + if (cmd === '?entrance') { + 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 | 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; + } + 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; } + 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 {} + await reply(`Entrance-Sound gesetzt: ${resolve}`); return; + } + if (cmd === '?exit') { + 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 | 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; + } + 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; } + 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 {} + await reply(`Exit-Sound gesetzt: ${resolve}`); return; + } + await reply('Unbekannter Command. Nutze ?help.'); +} + +async function ensureConnectionReady(connection: VoiceConnection, channelId: string, guildId: string, guild: any): Promise { + // Versuch 1: Warten ob bestehende Connection ready wird + 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...`); + } + + // Versuch 2: Rejoin + 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.warn(`${new Date().toISOString()} | VoiceConnection still not ready after rejoin`); + } + + // Versuch 3: Komplett neu verbinden + try { connection.destroy(); } catch {} + guildAudioState.delete(guildId); + + const newConn = joinVoiceChannel({ + channelId, + guildId, + adapterCreator: guild.voiceAdapterCreator as any, + selfMute: false, + selfDeaf: false + }); + try { + await entersState(newConn, VoiceConnectionStatus.Ready, 15_000); + console.log(`${new Date().toISOString()} | VoiceConnection ready after fresh join`); + return newConn; + } catch (e3) { + console.error(`${new Date().toISOString()} | VoiceConnection failed after all attempts, cleaning up`); + try { newConn.destroy(); } catch {} + guildAudioState.delete(guildId); + throw new Error('Voice connection failed after 3 attempts'); + } +} + +function attachVoiceLifecycle(state: GuildAudioState, guild: any) { + const { connection } = state; + // Mehrfach-Registrierung verhindern + if ((connection as any).__lifecycleAttached) return; + try { (connection as any).setMaxListeners?.(0); } catch {} + + // Retry-Tracking um Endlosschleife zu verhindern + let reconnectAttempts = 0; + const MAX_RECONNECT_ATTEMPTS = 3; + let isReconnecting = false; + + connection.on('stateChange', async (oldS: any, newS: any) => { + console.log(`${new Date().toISOString()} | VoiceConnection: ${oldS.status} -> ${newS.status}`); + + // Ready zurückgesetzt -> Retry-Counter reset + if (newS.status === VoiceConnectionStatus.Ready) { + reconnectAttempts = 0; + isReconnecting = false; + if (!connectedSince.has(state.guildId)) connectedSince.set(state.guildId, new Date().toISOString()); + return; + } + + // Re-Entranz verhindern: wenn schon ein Reconnect läuft, nicht nochmal starten + if (isReconnecting) return; + + 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 { + if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + reconnectAttempts++; + console.warn(`${new Date().toISOString()} | Disconnected rejoin attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`); + connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); + } else { + console.error(`${new Date().toISOString()} | Max reconnect attempts reached from Disconnected, destroying and rejoining fresh`); + reconnectAttempts = 0; + try { connection.destroy(); } catch {} + 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.Destroyed) { + connectedSince.delete(state.guildId); + // 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) { + isReconnecting = true; + try { + await entersState(connection, VoiceConnectionStatus.Ready, 15_000); + // Ready wird oben im Handler behandelt + } catch (e) { + reconnectAttempts++; + if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + const backoffMs = reconnectAttempts * 2_000; + console.warn(`${new Date().toISOString()} | Voice not ready from ${newS.status}, attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}, backoff ${backoffMs}ms`); + await new Promise(r => setTimeout(r, backoffMs)); + isReconnecting = false; + connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); + } else { + console.error(`${new Date().toISOString()} | Max reconnect attempts reached, destroying and rejoining fresh`); + reconnectAttempts = 0; + isReconnecting = false; + try { connection.destroy(); } catch {} + 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); + } + } + } + } catch (e) { + isReconnecting = false; + console.error(`${new Date().toISOString()} | Voice lifecycle handler error`, e); + } + }); + (connection as any).__lifecycleAttached = true; +} + +client.once(Events.ClientReady, () => { + console.log(`Bot eingeloggt als ${client.user?.tag}`); +}); + +// Voice State Updates: Entrance/Exit +client.on(Events.VoiceStateUpdate, async (oldState: VoiceState, newState: VoiceState) => { + try { + const userId = (newState.id || oldState.id) as string; + if (!userId) return; + // Eigene Events ignorieren + if (userId === client.user?.id) return; + const guildId = (newState.guild?.id || oldState.guild?.id) as string; + if (!guildId) return; + + const before = oldState.channelId; + const after = newState.channelId; + console.log(`${new Date().toISOString()} | VoiceStateUpdate user=${userId} before=${before ?? '-'} after=${after ?? '-'}`); + + // 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}`); + 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)) { + try { + // Dem Channel beitreten und Sound spielen + await playFilePath(guildId, after, abs, undefined, rel); + console.log(`${new Date().toISOString()} | Entrance played for ${userId}: ${rel}`); + } catch (e) { console.warn('Entrance play error', e); } + } + } + } + // 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}`); + 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)) { + try { + await playFilePath(guildId, before, abs, undefined, rel); + console.log(`${new Date().toISOString()} | Exit played for ${userId}: ${rel}`); + } catch (e) { console.warn('Exit play error', e); } + } + } + } 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}`); + } + } catch (e) { + console.warn('VoiceStateUpdate entrance/exit handling error', e); + } +}); + +client.on(Events.MessageCreate, async (message: Message) => { + try { + if (message.author?.bot) return; + // Commands überall annehmen (inkl. DMs) + const content = (message.content || '').trim(); + if (content.startsWith('?')) { + await handleCommand(message, content); + return; + } + // Dateiuploads nur per DM + if (!message.channel?.isDMBased?.()) return; + if (message.attachments.size === 0) return; + + for (const [, attachment] of message.attachments) { + const name = attachment.name ?? 'upload'; + const lower = name.toLowerCase(); + if (!(lower.endsWith('.mp3') || lower.endsWith('.wav'))) continue; + + 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; + const ext = path.parse(safeName).ext || (lower.endsWith('.wav') ? '.wav' : '.mp3'); + 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)); + // 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) { + 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) => { + 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(); + 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 { + 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')) }); +}); + +app.get('/api/sounds', (req: Request, res: Response) => { + const q = String(req.query.q ?? '').toLowerCase(); + const folderFilter = typeof req.query.folder === 'string' ? (req.query.folder as string) : '__all__'; + const categoryFilter = typeof (req.query as any).categoryId === 'string' ? String((req.query as any).categoryId) : undefined; + const fuzzyParam = String((req.query as any).fuzzy ?? '0'); + const useFuzzy = fuzzyParam === '1' || fuzzyParam === 'true'; + + const allItems = listAllSounds(); + + // Ordner-Statistik aus allItems ableiten + const folderCounts = new Map(); + 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 }); + } + + // 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)); + let itemsByFolder = allItems; + if (folderFilter !== '__all__') { + 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)); + } + } + // 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) { + 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)); + } + } + + const total = allItems.length; + const recentCount = Math.min(10, total); + // 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 }>; + + const foldersOut = [ + { key: '__all__', name: 'Alle', count: total }, + { key: '__recent__', name: 'Neu', count: recentCount }, + ...(top3.length ? [{ key: '__top3__', name: 'Most Played (3)', count: top3.length }] : []), + ...folders + ]; + // isRecent-Flag für UI (Top 5 der neuesten) + // Kategorie-Filter (virtuell) anwenden, wenn gesetzt + let result = filteredItems; + if (categoryFilter) { + const fc = persistedState.fileCategories ?? {}; + result = result.filter((it) => { + const key = it.relativePath ?? it.fileName; + const cats = fc[key] ?? []; + return cats.includes(categoryFilter); + }); + } + if (folderFilter === '__top3__') { + const keys = new Set(top3.map(t => t.key.split(':')[1])); + result = allItems.filter(i => keys.has(i.relativePath ?? i.fileName)); + } + + // 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; + }); + + res.json({ items: withRecentFlag, total, folders: foldersOut, categories: persistedState.categories ?? [], fileCategories: persistedState.fileCategories ?? {} }); +}); + +// --- 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) { + 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' }); + } + } 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' }); + 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 = 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 ?? [] }); +}); + +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 }); +}); + +// 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 }); +}); + +// 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 }); +}); + +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); + const result: Array<{ guildId: string; guildName: string; channelId: string; channelName: string; selected?: boolean }> = []; + 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) { + const sel = getSelectedChannelForGuild(guild.id); + result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name, selected: sel === ch.id }); + } + } + } + result.sort((a, b) => a.guildName.localeCompare(b.guildName) || a.channelName.localeCompare(b.channelName)); + res.json(result); +}); + +// 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' }); + } +}); + +app.post('/api/play', async (req: Request, res: Response) => { + try { + const { soundName, guildId, channelId, volume, folder, relativePath } = req.body as { + soundName?: string; + guildId?: string; + channelId?: string; + volume?: number; // 0..1 + folder?: string; // optional subfolder key + relativePath?: string; // optional direct relative path + }; + if (!soundName || !guildId || !channelId) return res.status(400).json({ error: 'soundName, guildId, channelId erforderlich' }); + + let filePath: string; + if (relativePath) filePath = path.join(SOUNDS_DIR, relativePath); + 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; + } + if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Sound nicht gefunden' }); + + // 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); + return res.status(500).json({ error: err?.message ?? 'Unbekannter Fehler' }); + } +}); + +// 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) { + // Kein aktiver Player: nur persistieren für nächste Wiedergabe + persistedState.volumes[guildId] = safeVolume; + writePersistedState(persistedState); + // Broadcast neue Lautstärke an alle Clients + sseBroadcast({ type: 'volume', guildId, volume: safeVolume }); + return res.json({ ok: true, volume: safeVolume, persistedOnly: true }); + } + state.currentVolume = safeVolume; + if (state.currentResource?.volume) { + state.currentResource.volume.setVolume(safeVolume); + console.log(`${new Date().toISOString()} | live setVolume(${safeVolume}) guild=${guildId}`); + } + persistedState.volumes[guildId] = safeVolume; + writePersistedState(persistedState); + // Broadcast neue Lautstärke an alle Clients + sseBroadcast({ type: 'volume', guildId, volume: safeVolume }); + 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' }); + } +}); + +// 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 }); +}); + +// 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); + // 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); + if (t) clearTimeout(t); + partyTimers.delete(guildId); + partyActive.delete(guildId); + sseBroadcast({ type: 'party', guildId, active: false }); + } 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(); + // Broadcast Status + sseBroadcast({ type: 'party', guildId, active: true, channelId }); +} + +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); + sseBroadcast({ type: 'party', guildId: id, active: false }); + return res.json({ ok: true }); + } catch (e: any) { + console.error('party/stop error', e); + return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' }); + } +}); + +// 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 + try { + const statsSnap: Record = {}; + for (const [gId, st] of guildAudioState) { + const status = st.connection.state?.status ?? 'unknown'; + if (status === 'ready' && !connectedSince.has(gId)) { + connectedSince.set(gId, new Date().toISOString()); + } + const ch = client.channels.cache.get(st.channelId); + statsSnap[gId] = { + voicePing: (st.connection.ping as any)?.ws ?? null, + gatewayPing: client.ws.ping, + status, + channelName: ch && 'name' in ch ? (ch as any).name : null, + connectedSince: connectedSince.get(gId) ?? null, + }; + } + res.write(`data: ${JSON.stringify({ type: 'snapshot', party: Array.from(partyActive), selected: persistedState.selectedChannels ?? {}, volumes: persistedState.volumes ?? {}, nowplaying: Object.fromEntries(nowPlaying), voicestats: statsSnap })}\n\n`); + } catch {} + + // Ping, damit Proxies die Verbindung offen halten + const ping = setInterval(() => { try { res.write(':\n\n'); } catch {} }, 15_000); + + sseClients.add(res); + req.on('close', () => { + sseClients.delete(res); + clearInterval(ping); + try { res.end(); } catch {} + }); +}); + +// --- 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)) { + app.use(express.static(webDistPath)); + app.get('/{*splat}', (_req, res) => { + res.sendFile(path.join(webDistPath, 'index.html')); + }); +} + +// Node 24 warnt bei negativen Timeout-Werten (aus @discordjs/voice intern) - harmlos unterdruecken +process.on('warning', (warning) => { + if (warning.name === 'TimeoutNegativeWarning') return; + console.warn(warning.name + ': ' + warning.message); +}); +process.on('uncaughtException', (err) => { + console.error(`FATAL uncaughtException:`, err); + process.exit(1); +}); +process.on('unhandledRejection', (reason) => { + console.error(`FATAL unhandledRejection:`, reason); +}); + +app.listen(PORT, () => { + console.log(`Server läuft auf http://0.0.0.0:${PORT}`); + + // Vollständige Cache-Synchronisation beim Start (Hintergrund) + syncNormCache(); + + // Voice-Stats alle 5 Sekunden an alle SSE-Clients broadcasten + setInterval(() => { + if (sseClients.size === 0 || guildAudioState.size === 0) return; + for (const [gId, st] of guildAudioState) { + const status = st.connection.state?.status ?? 'unknown'; + if (status === 'ready' && !connectedSince.has(gId)) { + connectedSince.set(gId, new Date().toISOString()); + } + const ch = client.channels.cache.get(st.channelId); + sseBroadcast({ + type: 'voicestats', + guildId: gId, + voicePing: (st.connection.ping as any)?.ws ?? null, + gatewayPing: client.ws.ping, + status, + channelName: ch && 'name' in ch ? (ch as any).name : null, + connectedSince: connectedSince.get(gId) ?? null, + }); + } + }, 5_000); +}); + + + + diff --git a/web/src/styles.css b/web/src/styles.css index d40decd..daeda9b 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -106,6 +106,7 @@ html, body { height: 100%; + overflow: hidden; background: var(--bg-deep); color: var(--text-normal); font-family: var(--font); @@ -376,6 +377,7 @@ input, select { border-radius: 16px; width: 340px; box-shadow: 0 20px 60px rgba(0,0,0,.4); + overflow: hidden; animation: slideUp .2s ease; } @keyframes slideUp { @@ -969,6 +971,7 @@ input, select { transition: all var(--transition); border: 2px solid transparent; user-select: none; + overflow: hidden; aspect-ratio: 1; opacity: 0; animation: card-enter 350ms ease-out forwards; @@ -1064,6 +1067,8 @@ input, select { color: var(--text-normal); z-index: 1; max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; padding: 0 4px; } @@ -1689,6 +1694,8 @@ input, select { font-size: 14px; font-weight: 600; color: var(--text-normal); + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } @@ -1696,6 +1703,8 @@ input, select { margin-top: 3px; font-size: 11px; color: var(--text-faint); + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } From 390d6eb57524a4ea0998a7e6604c42c500234348 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 5 Mar 2026 21:16:37 +0100 Subject: [PATCH 27/35] fix: solid background for connection modal --- web/src/styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/styles.css b/web/src/styles.css index daeda9b..400fc21 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -372,7 +372,7 @@ input, select { animation: fadeIn .15s ease; } .conn-modal { - background: var(--card); + background: var(--bg-primary); border: 1px solid var(--border); border-radius: 16px; width: 340px; From ac96896055d0c2b699884894c6507cf7eca03f1a Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 5 Mar 2026 21:29:43 +0100 Subject: [PATCH 28/35] fix: prevent RAM spike from large PCM files (e.g. 2h elevator) - Check file size with statSync BEFORE reading into memory - Files >50MB (~4.5min PCM) are never loaded into RAM, always streamed - Also skip if total cache would exceed limit (no more read-then-discard) - Fixes 1GB+ RAM spike when playing long audio files --- server/src/index.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index c01a72a..54c5ec7 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -162,19 +162,20 @@ fs.mkdirSync(NORM_CACHE_DIR, { recursive: true }); // Danach kein Disk-I/O mehr bei Cache-Hits -> nahezu instant playback. const pcmMemoryCache = new Map(); const PCM_MEMORY_CACHE_MAX_MB = Number(process.env.PCM_CACHE_MAX_MB ?? '512'); +const PCM_PER_FILE_MAX_MB = 50; // Max 50 MB pro Datei (~4.5 min PCM) - groessere werden gestreamt let pcmMemoryCacheBytes = 0; function getPcmFromMemory(cachedPath: string): Buffer | null { const buf = pcmMemoryCache.get(cachedPath); if (buf) return buf; - // Erste Anfrage: von Disk in RAM laden try { + // Dateigroesse VORHER pruefen - keine grossen Files in den RAM laden + const stat = fs.statSync(cachedPath); + if (stat.size > PCM_PER_FILE_MAX_MB * 1024 * 1024) return null; + if (pcmMemoryCacheBytes + stat.size > PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) return null; const data = fs.readFileSync(cachedPath); - const newTotal = pcmMemoryCacheBytes + data.byteLength; - if (newTotal <= PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) { - pcmMemoryCache.set(cachedPath, data); - pcmMemoryCacheBytes = newTotal; - } + pcmMemoryCache.set(cachedPath, data); + pcmMemoryCacheBytes += data.byteLength; return data; } catch { return null; } } From 4875747dc5b792162d51e1869ac3617dd50cea59 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 6 Mar 2026 20:04:27 +0100 Subject: [PATCH 29/35] Feat: Show member count in channel dropdown Add voice channel member count (non-bot users) to channel API response and display it in the dropdown button and menu options. Shows (N) suffix when users are in a channel. Co-Authored-By: Claude Opus 4.6 --- server/src/index.ts | 9 ++++++--- web/src/App.tsx | 4 ++-- web/src/types.ts | 1 + 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 54c5ec7..834d654 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -5,7 +5,7 @@ import express, { Request, Response } from 'express'; 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'; +import { Client, GatewayIntentBits, Partials, ChannelType, Events, type Message, type VoiceBasedChannel, VoiceState } from 'discord.js'; import { joinVoiceChannel, createAudioPlayer, @@ -1291,14 +1291,17 @@ 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); - const result: Array<{ guildId: string; guildName: string; channelId: string; channelName: string; selected?: boolean }> = []; + const result: Array<{ guildId: string; guildName: string; channelId: string; channelName: string; members: number; selected?: boolean }> = []; 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) { const sel = getSelectedChannelForGuild(guild.id); - result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name, selected: sel === ch.id }); + const members = ('members' in ch) + ? (ch as VoiceBasedChannel).members.filter(m => !m.user.bot).size + : 0; + result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name, members, selected: sel === ch.id }); } } } diff --git a/web/src/App.tsx b/web/src/App.tsx index 77f8e1d..7dc7421 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -610,7 +610,7 @@ export default function App() { > headset {selected && } - {selectedChannel?.channelName || 'Channel...'} + {selectedChannel ? `${selectedChannel.channelName}${selectedChannel.members ? ` (${selectedChannel.members})` : ''}` : 'Channel...'} expand_more {channelOpen && ( @@ -625,7 +625,7 @@ export default function App() { onClick={() => handleChannelSelect(ch)} > volume_up - {ch.channelName} + {ch.channelName}{ch.members ? ` (${ch.members})` : ''}
))} diff --git a/web/src/types.ts b/web/src/types.ts index 919e8fe..6222add 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -20,6 +20,7 @@ export type VoiceChannelInfo = { guildName: string; channelId: string; channelName: string; + members?: number; selected?: boolean; }; From 3c8ad63f99c84bb3483d46a65b6e5a8675ef080f Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 7 Mar 2026 00:13:48 +0100 Subject: [PATCH 30/35] feat: YouTube/Instagram/MP3 download with modal + yt-dlp support Sync from gaming-hub soundboard plugin: - Add yt-dlp URL detection (YouTube, Instagram) + direct MP3 support - downloadWithYtDlp() with verbose logging, error detection, fallback scan - handleUrlDownload() shared logic with custom filename + rename - Download modal: filename input, progress spinner, success/error phases - URL type badges (YT/IG/MP3) in toolbar input - Auto-prepend https:// for URLs without protocol - Fix Dockerfile: yt-dlp_linux standalone binary (no Python needed) - download-url route (admin-only, save without playing) Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 2 +- server/src/index.ts | 256 ++++++++++++++++++++++++++++++++++++++------ web/src/App.tsx | 208 +++++++++++++++++++++++++++++++---- web/src/api.ts | 21 ++-- web/src/styles.css | 153 ++++++++++++++++++++++++++ 5 files changed, 579 insertions(+), 61 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2883406..3373068 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,7 +41,7 @@ ENV PORT=8080 ENV SOUNDS_DIR=/data/sounds RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \ - && curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp \ + && curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux -o /usr/local/bin/yt-dlp \ && chmod a+rx /usr/local/bin/yt-dlp \ && apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* diff --git a/server/src/index.ts b/server/src/index.ts index 834d654..6bc11b5 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -22,7 +22,6 @@ import { } from '@discordjs/voice'; 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, Readable } from 'node:stream'; @@ -46,6 +45,187 @@ if (!DISCORD_TOKEN) { fs.mkdirSync(SOUNDS_DIR, { recursive: true }); +// ── yt-dlp URL detection ── +const YTDLP_HOSTS = [ + 'youtube.com', 'www.youtube.com', 'm.youtube.com', 'youtu.be', + 'music.youtube.com', + 'instagram.com', 'www.instagram.com', +]; + +function isYtDlpUrl(url: string): boolean { + try { + const host = new URL(url).hostname.toLowerCase(); + return YTDLP_HOSTS.some(h => host === h || host.endsWith('.' + h)); + } catch { return false; } +} + +function isDirectMp3Url(url: string): boolean { + try { + return new URL(url).pathname.toLowerCase().endsWith('.mp3'); + } catch { return false; } +} + +function isSupportedUrl(url: string): boolean { + return isYtDlpUrl(url) || isDirectMp3Url(url); +} + +/** Download audio via yt-dlp → MP3 file in SOUNDS_DIR */ +function downloadWithYtDlp(url: string): Promise<{ filename: string; filepath: string }> { + return new Promise((resolve, reject) => { + const outputTemplate = path.join(SOUNDS_DIR, '%(title)s.%(ext)s'); + const args = [ + '-x', // extract audio only + '--audio-format', 'mp3', // convert to MP3 + '--audio-quality', '0', // best quality + '-o', outputTemplate, // output path template + '--no-playlist', // single video only + '--no-overwrites', // don't overwrite existing + '--restrict-filenames', // safe filenames (ASCII, no spaces) + '--max-filesize', '50m', // same limit as file upload + '--socket-timeout', '30', // timeout for slow connections + '--verbose', // verbose output for logging + url, + ]; + + const startTime = Date.now(); + console.log(`[Jukebox] [yt-dlp] ▶ START url=${url}`); + console.log(`[Jukebox] [yt-dlp] args: yt-dlp ${args.join(' ')}`); + + const proc = child_process.spawn('yt-dlp', args, { stdio: ['ignore', 'pipe', 'pipe'] }); + + let stdout = ''; + let stderr = ''; + proc.stdout?.on('data', (d: Buffer) => { + const line = d.toString(); + stdout += line; + for (const l of line.split('\n').filter((s: string) => s.trim())) { + console.log(`[Jukebox] [yt-dlp:out] ${l.trim()}`); + } + }); + proc.stderr?.on('data', (d: Buffer) => { + const line = d.toString(); + stderr += line; + for (const l of line.split('\n').filter((s: string) => s.trim())) { + console.error(`[Jukebox] [yt-dlp:err] ${l.trim()}`); + } + }); + + proc.on('error', (err) => { + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.error(`[Jukebox] [yt-dlp] ✗ SPAWN ERROR after ${elapsed}s: ${err.message}`); + reject(new Error('yt-dlp nicht verfügbar')); + }); + + proc.on('close', (code) => { + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + + if (code !== 0) { + console.error(`[Jukebox] [yt-dlp] ✗ FAILED exit=${code} after ${elapsed}s`); + console.error(`[Jukebox] [yt-dlp] stderr (last 1000 chars): ${stderr.slice(-1000)}`); + console.error(`[Jukebox] [yt-dlp] stdout (last 500 chars): ${stdout.slice(-500)}`); + + if (stderr.includes('Video unavailable') || stderr.includes('is not available')) + reject(new Error('Video nicht verfügbar')); + else if (stderr.includes('Private video')) + reject(new Error('Privates Video')); + else if (stderr.includes('Sign in') || stderr.includes('login')) + reject(new Error('Login erforderlich')); + else if (stderr.includes('exceeds maximum')) + reject(new Error('Datei zu groß (max 50 MB)')); + else if (stderr.includes('Unsupported URL')) + reject(new Error('URL nicht unterstützt')); + else if (stderr.includes('HTTP Error 404')) + reject(new Error('Video nicht gefunden (404)')); + else if (stderr.includes('HTTP Error 403')) + reject(new Error('Zugriff verweigert (403)')); + else + reject(new Error(`yt-dlp Fehler (exit ${code})`)); + return; + } + + console.log(`[Jukebox] [yt-dlp] ✓ DONE exit=0 after ${elapsed}s`); + + const destMatch = stdout.match(/\[ExtractAudio\] Destination: (.+\.mp3)/i) + ?? stdout.match(/\[download\] (.+\.mp3) has already been downloaded/i) + ?? stdout.match(/Destination: (.+\.mp3)/i); + + if (destMatch) { + const filepath = destMatch[1].trim(); + const filename = path.basename(filepath); + console.log(`[Jukebox] [yt-dlp] saved: ${filename} (regex match)`); + resolve({ filename, filepath }); + return; + } + + // Fallback: scan SOUNDS_DIR for newest MP3 (within last 60s) + const now = Date.now(); + const mp3s = fs.readdirSync(SOUNDS_DIR) + .filter(f => f.endsWith('.mp3')) + .map(f => ({ name: f, mtime: fs.statSync(path.join(SOUNDS_DIR, f)).mtimeMs })) + .filter(f => now - f.mtime < 60000) + .sort((a, b) => b.mtime - a.mtime); + + if (mp3s.length > 0) { + const filename = mp3s[0].name; + console.log(`[Jukebox] [yt-dlp] saved: ${filename} (fallback scan, ${mp3s.length} recent files)`); + resolve({ filename, filepath: path.join(SOUNDS_DIR, filename) }); + return; + } + + console.error(`[Jukebox] [yt-dlp] ✗ OUTPUT FILE NOT FOUND`); + console.error(`[Jukebox] [yt-dlp] full stdout:\n${stdout}`); + console.error(`[Jukebox] [yt-dlp] full stderr:\n${stderr}`); + reject(new Error('Download abgeschlossen, aber Datei nicht gefunden')); + }); + }); +} + +/** Shared download logic for play-url and download-url */ +async function handleUrlDownload(url: string, customFilename?: string): Promise<{ savedFile: string; savedPath: string }> { + let savedFile: string; + let savedPath: string; + + if (isYtDlpUrl(url)) { + console.log(`[Jukebox] [url-dl] → yt-dlp...`); + const result = await downloadWithYtDlp(url); + savedFile = result.filename; + savedPath = result.filepath; + } else { + const parsed = new URL(url); + savedFile = path.basename(parsed.pathname); + savedPath = path.join(SOUNDS_DIR, savedFile); + console.log(`[Jukebox] [url-dl] → direct MP3: ${savedFile}`); + const r = await fetch(url); + if (!r.ok) throw new Error(`Download fehlgeschlagen (HTTP ${r.status})`); + const buf = Buffer.from(await r.arrayBuffer()); + fs.writeFileSync(savedPath, buf); + console.log(`[Jukebox] [url-dl] saved ${(buf.length / 1024).toFixed(0)} KB → ${savedFile}`); + } + + // Rename if custom filename provided + if (customFilename) { + const safeName = customFilename.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim(); + if (safeName) { + const ext = path.extname(savedFile).toLowerCase() || '.mp3'; + const newName = safeName.endsWith(ext) ? safeName : safeName + ext; + const newPath = path.join(SOUNDS_DIR, newName); + if (newPath !== savedPath && !fs.existsSync(newPath)) { + fs.renameSync(savedPath, newPath); + console.log(`[Jukebox] [url-dl] renamed: ${savedFile} → ${newName}`); + savedFile = newName; + savedPath = newPath; + } + } + } + + if (NORMALIZE_ENABLE) { + try { await normalizeToCache(savedPath); console.log(`[Jukebox] [url-dl] normalized`); } + catch (e: any) { console.error(`[Jukebox] [url-dl] normalize failed: ${e?.message}`); } + } + + return { savedFile, savedPath }; +} + // Persistenter Zustand: Lautstärke/Plays + Kategorien type Category = { id: string; name: string; color?: string; sort?: number }; type PersistedState = { @@ -1557,45 +1737,57 @@ app.get('/api/events', (req: Request, res: Response) => { }); }); -// --- Medien-URL abspielen --- -// Unterstützt: direkte MP3-URL (Download und Ablage) +// --- Medien-URL abspielen (YouTube / Instagram / MP3) --- app.post('/api/play-url', async (req: Request, res: Response) => { + const startTime = Date.now(); 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 { url, guildId, channelId, volume, filename } = req.body as { url?: string; guildId?: string; channelId?: string; volume?: number; filename?: string }; + const urlType = isYtDlpUrl(url || '') ? 'yt-dlp' : isDirectMp3Url(url || '') ? 'mp3' : 'unknown'; + console.log(`[Jukebox] [play-url] ▶ type=${urlType} url=${url} filename=${filename || '(auto)'} guild=${guildId}`); - 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) }); + if (!url || !guildId || !channelId) return res.status(400).json({ error: 'url, guildId, channelId erforderlich' }); + try { new URL(url); } catch { return res.status(400).json({ error: 'Ungültige URL' }); } + if (!isSupportedUrl(url)) return res.status(400).json({ error: 'Nur YouTube, Instagram oder direkte MP3-Links' }); + + const { savedFile, savedPath } = await handleUrlDownload(url, filename); + + try { await playFilePath(guildId, channelId, savedPath, volume, savedFile); console.log(`[Jukebox] [play-url] playing`); } + catch (e: any) { console.error(`[Jukebox] [play-url] play failed (file saved): ${e?.message}`); } + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.log(`[Jukebox] [play-url] ✓ DONE in ${elapsed}s → ${savedFile}`); + return res.json({ ok: true, saved: savedFile }); } catch (e: any) { - console.error('play-url error:', e); + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.error(`[Jukebox] [play-url] ✗ ERROR after ${elapsed}s: ${e?.message ?? e}`); return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' }); } }); +// --- URL nur herunterladen (ohne Abspielen) --- +app.post('/api/download-url', requireAdmin, async (req: Request, res: Response) => { + const startTime = Date.now(); + try { + const { url, filename } = req.body as { url?: string; filename?: string }; + const urlType = isYtDlpUrl(url || '') ? 'yt-dlp' : isDirectMp3Url(url || '') ? 'mp3' : 'unknown'; + console.log(`[Jukebox] [download-url] ▶ type=${urlType} url=${url} filename=${filename || '(auto)'}`); + + if (!url) return res.status(400).json({ error: 'URL erforderlich' }); + try { new URL(url); } catch { return res.status(400).json({ error: 'Ungültige URL' }); } + if (!isSupportedUrl(url)) return res.status(400).json({ error: 'Nur YouTube, Instagram oder direkte MP3-Links' }); + + const { savedFile } = await handleUrlDownload(url, filename); + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.log(`[Jukebox] [download-url] ✓ DONE in ${elapsed}s → ${savedFile}`); + return res.json({ ok: true, saved: savedFile }); + } catch (e: any) { + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.error(`[Jukebox] [download-url] ✗ ERROR after ${elapsed}s: ${e?.message ?? e}`); + return res.status(500).json({ error: e?.message ?? 'Fehler' }); + } +}); + // Static Frontend ausliefern (Vite build) const webDistPath = path.resolve(__dirname, '../../web/dist'); if (fs.existsSync(webDistPath)) { diff --git a/web/src/App.tsx b/web/src/App.tsx index 7dc7421..f67f287 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { - fetchChannels, fetchSounds, fetchAnalytics, playSound, playUrl, setVolumeLive, getVolume, + fetchChannels, fetchSounds, fetchAnalytics, playSound, playUrl, downloadUrl, setVolumeLive, getVolume, adminStatus, adminLogin, adminLogout, adminDelete, adminRename, fetchCategories, partyStart, partyStop, subscribeEvents, getSelectedChannels, setSelectedChannel, uploadFile, @@ -52,6 +52,13 @@ export default function App() { const [importUrl, setImportUrl] = useState(''); const [importBusy, setImportBusy] = useState(false); + // Download modal state + const [dlModal, setDlModal] = useState<{ + url: string; type: 'youtube' | 'instagram' | 'mp3' | null; + filename: string; phase: 'input' | 'downloading' | 'done' | 'error'; + savedName?: string; error?: string; + } | null>(null); + /* ── Channels ── */ const [channels, setChannels] = useState([]); const [selected, setSelected] = useState(''); @@ -153,14 +160,35 @@ export default function App() { setTimeout(() => setNotification(null), 3000); }, []); const soundKey = useCallback((s: Sound) => s.relativePath ?? s.fileName, []); - const isMp3Url = useCallback((value: string) => { + const YTDLP_HOSTS = ['youtube.com', 'www.youtube.com', 'm.youtube.com', 'youtu.be', 'music.youtube.com', 'instagram.com', 'www.instagram.com']; + /** Auto-prepend https:// if missing */ + const normalizeUrl = useCallback((value: string): string => { + const v = value.trim(); + if (!v) return v; + if (/^https?:\/\//i.test(v)) return v; + return 'https://' + v; + }, []); + const isSupportedUrl = useCallback((value: string) => { try { - const parsed = new URL(value.trim()); - return parsed.pathname.toLowerCase().endsWith('.mp3'); + const parsed = new URL(normalizeUrl(value)); + const host = parsed.hostname.toLowerCase(); + if (parsed.pathname.toLowerCase().endsWith('.mp3')) return true; + if (YTDLP_HOSTS.some(h => host === h || host.endsWith('.' + h))) return true; + return false; } catch { return false; } - }, []); + }, [normalizeUrl]); + const getUrlType = useCallback((value: string): 'youtube' | 'instagram' | 'mp3' | null => { + try { + const parsed = new URL(normalizeUrl(value)); + const host = parsed.hostname.toLowerCase(); + if (host.includes('youtube') || host === 'youtu.be') return 'youtube'; + if (host.includes('instagram')) return 'instagram'; + if (parsed.pathname.toLowerCase().endsWith('.mp3')) return 'mp3'; + return null; + } catch { return null; } + }, [normalizeUrl]); const guildId = selected ? selected.split(':')[0] : ''; const channelId = selected ? selected.split(':')[1] : ''; @@ -346,22 +374,42 @@ export default function App() { } catch (e: any) { notify(e?.message || 'Play fehlgeschlagen', 'error'); } } - async function handleUrlImport() { - const trimmed = importUrl.trim(); - if (!trimmed) return notify('Bitte einen MP3-Link eingeben', 'error'); - if (!selected) return notify('Bitte einen Voice-Channel auswählen', 'error'); - if (!isMp3Url(trimmed)) return notify('Nur direkte MP3-Links sind erlaubt', 'error'); - setImportBusy(true); + // Open download modal instead of downloading directly + function handleUrlImport() { + const trimmed = normalizeUrl(importUrl); + if (!trimmed) return notify('Bitte einen Link eingeben', 'error'); + if (!isSupportedUrl(trimmed)) return notify('Nur YouTube, Instagram oder direkte MP3-Links', 'error'); + const urlType = getUrlType(trimmed); + // Pre-fill filename for MP3 links (basename without .mp3), empty for YT/IG + let defaultName = ''; + if (urlType === 'mp3') { + try { defaultName = new URL(trimmed).pathname.split('/').pop()?.replace(/\.mp3$/i, '') ?? ''; } catch {} + } + setDlModal({ url: trimmed, type: urlType, filename: defaultName, phase: 'input' }); + } + + // Actual download triggered from modal + async function handleModalDownload() { + if (!dlModal) return; + setDlModal(prev => prev ? { ...prev, phase: 'downloading' } : null); try { - await playUrl(trimmed, guildId, channelId, volume); + let savedName: string | undefined; + const fn = dlModal.filename.trim() || undefined; + if (selected && guildId && channelId) { + const result = await playUrl(dlModal.url, guildId, channelId, volume, fn); + savedName = result.saved; + } else { + const result = await downloadUrl(dlModal.url, fn); + savedName = result.saved; + } + setDlModal(prev => prev ? { ...prev, phase: 'done', savedName } : null); setImportUrl(''); - notify('MP3 importiert und abgespielt'); setRefreshKey(k => k + 1); - await loadAnalytics(); + void loadAnalytics(); + // Auto-close after 2.5s + setTimeout(() => setDlModal(null), 2500); } catch (e: any) { - notify(e?.message || 'URL-Import fehlgeschlagen', 'error'); - } finally { - setImportBusy(false); + setDlModal(prev => prev ? { ...prev, phase: 'error', error: e?.message || 'Fehler' } : null); } } @@ -715,20 +763,32 @@ export default function App() {
- link + + {getUrlType(importUrl) === 'youtube' ? 'smart_display' + : getUrlType(importUrl) === 'instagram' ? 'photo_camera' + : 'link'} + setImportUrl(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') void handleUrlImport(); }} /> + {importUrl && ( + + {getUrlType(importUrl) === 'youtube' ? 'YT' + : getUrlType(importUrl) === 'instagram' ? 'IG' + : getUrlType(importUrl) === 'mp3' ? 'MP3' + : '?'} + + )} @@ -1252,6 +1312,110 @@ export default function App() {
)} + + {/* ── Download Modal ── */} + {dlModal && ( +
dlModal.phase !== 'downloading' && setDlModal(null)}> +
e.stopPropagation()}> +
+ + {dlModal.type === 'youtube' ? 'smart_display' : dlModal.type === 'instagram' ? 'photo_camera' : 'audio_file'} + + + {dlModal.phase === 'input' ? 'Sound herunterladen' + : dlModal.phase === 'downloading' ? 'Wird heruntergeladen...' + : dlModal.phase === 'done' ? 'Fertig!' + : 'Fehler'} + + {dlModal.phase !== 'downloading' && ( + + )} +
+ +
+ {/* URL badge */} +
+ + {dlModal.type === 'youtube' ? 'YouTube' : dlModal.type === 'instagram' ? 'Instagram' : 'MP3'} + + + {dlModal.url.length > 60 ? dlModal.url.slice(0, 57) + '...' : dlModal.url} + +
+ + {/* Filename input (input phase only) */} + {dlModal.phase === 'input' && ( +
+ +
+ setDlModal(prev => prev ? { ...prev, filename: e.target.value } : null)} + onKeyDown={e => { if (e.key === 'Enter') void handleModalDownload(); }} + autoFocus + /> + .mp3 +
+ Leer lassen = automatischer Name +
+ )} + + {/* Progress (downloading phase) */} + {dlModal.phase === 'downloading' && ( +
+
+ + {dlModal.type === 'youtube' || dlModal.type === 'instagram' + ? 'Audio wird extrahiert...' + : 'MP3 wird heruntergeladen...'} + +
+ )} + + {/* Success */} + {dlModal.phase === 'done' && ( +
+ check_circle + Gespeichert als {dlModal.savedName} +
+ )} + + {/* Error */} + {dlModal.phase === 'error' && ( +
+ error + {dlModal.error} +
+ )} +
+ + {/* Actions */} + {dlModal.phase === 'input' && ( +
+ + +
+ )} + {dlModal.phase === 'error' && ( +
+ + +
+ )} +
+
+ )}
); } diff --git a/web/src/api.ts b/web/src/api.ts index 4cf8736..74f7523 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -195,15 +195,24 @@ export async function adminRename(from: string, to: string): Promise { return data?.to as string; } -export async function playUrl(url: string, guildId: string, channelId: string, volume: number): Promise { +export async function playUrl(url: string, guildId: string, channelId: string, volume: number, filename?: string): Promise<{ saved?: string }> { const res = await fetch(`${API_BASE}/play-url`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url, guildId, channelId, volume }) + body: JSON.stringify({ url, guildId, channelId, volume, filename }) }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data?.error || 'Play-URL fehlgeschlagen'); - } + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error || 'Play-URL fehlgeschlagen'); + return data; +} + +export async function downloadUrl(url: string, filename?: string): Promise<{ saved?: string }> { + const res = await fetch(`${API_BASE}/download-url`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, filename }) + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error || 'Download fehlgeschlagen'); + return data; } /** Lädt eine einzelne MP3/WAV-Datei hoch. Gibt den gespeicherten Dateinamen zurück. */ diff --git a/web/src/styles.css b/web/src/styles.css index 400fc21..e0efd56 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -641,6 +641,24 @@ input, select { pointer-events: none; } +.url-import-tag { + flex-shrink: 0; + padding: 1px 6px; + border-radius: 8px; + font-size: 10px; + font-weight: 800; + letter-spacing: .5px; + text-transform: uppercase; +} +.url-import-tag.valid { + background: rgba(46, 204, 113, .18); + color: #2ecc71; +} +.url-import-tag.invalid { + background: rgba(231, 76, 60, .18); + color: #e74c3c; +} + /* ── Toolbar Buttons ── */ .tb-btn { display: flex; @@ -2063,6 +2081,141 @@ input, select { margin-top: 2px; } +/* ──────────────────────────────────────────── + Download Modal + ──────────────────────────────────────────── */ +.dl-modal-overlay { + position: fixed; inset: 0; + background: rgba(0, 0, 0, .55); + display: flex; align-items: center; justify-content: center; + z-index: 300; + animation: fade-in 150ms ease; +} +@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } + +.dl-modal { + width: 420px; max-width: 92vw; + background: var(--bg-secondary); + border: 1px solid rgba(255, 255, 255, .1); + border-radius: 16px; + box-shadow: 0 12px 60px rgba(0, 0, 0, .5); + animation: scale-in 200ms cubic-bezier(.16, 1, .3, 1); +} +@keyframes scale-in { from { opacity: 0; transform: scale(.95); } to { opacity: 1; transform: scale(1); } } + +.dl-modal-header { + display: flex; align-items: center; gap: 8px; + padding: 14px 16px; + border-bottom: 1px solid rgba(255, 255, 255, .06); + font-size: 14px; font-weight: 700; + color: var(--text-normal); +} +.dl-modal-header .material-icons { color: var(--accent); } + +.dl-modal-close { + margin-left: auto; + display: flex; align-items: center; justify-content: center; + width: 26px; height: 26px; border-radius: 50%; + border: none; background: rgba(255,255,255,.06); + color: var(--text-muted); cursor: pointer; + transition: background var(--transition); +} +.dl-modal-close:hover { background: rgba(255,255,255,.14); color: var(--text-normal); } + +.dl-modal-body { padding: 16px; display: flex; flex-direction: column; gap: 14px; } + +/* URL display */ +.dl-modal-url { + display: flex; align-items: center; gap: 8px; + padding: 8px 10px; border-radius: 8px; + background: rgba(0, 0, 0, .2); + overflow: hidden; +} +.dl-modal-tag { + flex-shrink: 0; padding: 2px 8px; border-radius: 6px; + font-size: 10px; font-weight: 800; letter-spacing: .5px; text-transform: uppercase; +} +.dl-modal-tag.youtube { background: rgba(255, 0, 0, .18); color: #ff4444; } +.dl-modal-tag.instagram { background: rgba(225, 48, 108, .18); color: #e1306c; } +.dl-modal-tag.mp3 { background: rgba(46, 204, 113, .18); color: #2ecc71; } +.dl-modal-url-text { + font-size: 11px; color: var(--text-faint); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} + +/* Filename field */ +.dl-modal-field { display: flex; flex-direction: column; gap: 5px; } +.dl-modal-label { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .5px; } +.dl-modal-input-wrap { + display: flex; align-items: center; + border: 1px solid rgba(255, 255, 255, .1); border-radius: 8px; + background: rgba(0, 0, 0, .15); + overflow: hidden; + transition: border-color var(--transition); +} +.dl-modal-input-wrap:focus-within { border-color: var(--accent); } +.dl-modal-input { + flex: 1; border: none; background: transparent; + padding: 8px 10px; color: var(--text-normal); + font-size: 13px; font-family: var(--font); outline: none; +} +.dl-modal-input::placeholder { color: var(--text-faint); } +.dl-modal-ext { + padding: 0 10px; font-size: 12px; font-weight: 600; + color: var(--text-faint); background: rgba(255, 255, 255, .04); + align-self: stretch; display: flex; align-items: center; +} +.dl-modal-hint { font-size: 10px; color: var(--text-faint); } + +/* Progress spinner */ +.dl-modal-progress { + display: flex; align-items: center; gap: 12px; + padding: 20px 0; justify-content: center; + font-size: 13px; color: var(--text-muted); +} +.dl-modal-spinner { + width: 24px; height: 24px; border-radius: 50%; + border: 3px solid rgba(var(--accent-rgb), .2); + border-top-color: var(--accent); + animation: spin 800ms linear infinite; +} + +/* Success */ +.dl-modal-success { + display: flex; align-items: center; gap: 10px; + padding: 16px 0; justify-content: center; + font-size: 13px; color: var(--text-normal); +} +.dl-modal-check { color: #2ecc71; font-size: 28px; } + +/* Error */ +.dl-modal-error { + display: flex; align-items: center; gap: 10px; + padding: 12px 0; justify-content: center; + font-size: 13px; color: #e74c3c; +} + +/* Actions */ +.dl-modal-actions { + display: flex; justify-content: flex-end; gap: 8px; + padding: 0 16px 14px; +} +.dl-modal-cancel { + padding: 7px 14px; border-radius: 8px; + border: 1px solid rgba(255, 255, 255, .1); background: transparent; + color: var(--text-muted); font-size: 12px; font-weight: 600; + cursor: pointer; transition: all var(--transition); +} +.dl-modal-cancel:hover { background: rgba(255,255,255,.06); color: var(--text-normal); } +.dl-modal-submit { + display: flex; align-items: center; gap: 5px; + padding: 7px 16px; border-radius: 8px; + border: none; background: var(--accent); + color: #fff; font-size: 12px; font-weight: 700; + cursor: pointer; transition: filter var(--transition); +} +.dl-modal-submit:hover { filter: brightness(1.15); } + /* ──────────────────────────────────────────── Utility ──────────────────────────────────────────── */ From 3f810341163883b41dbea8220779c1061147e08a Mon Sep 17 00:00:00 2001 From: Administrator Date: Sat, 7 Mar 2026 11:24:49 +0000 Subject: [PATCH 31/35] CI: Switch registry to adriahub (192.168.1.100:5050) --- .gitlab-ci.yml | 124 ++++++++++++++++++++++++------------------------- 1 file changed, 61 insertions(+), 63 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8da7939..a3bb358 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,63 +1,61 @@ -stages: - - build - -variables: - INTERNAL_REGISTRY: "10.10.10.10:9080" - IMAGE_NAME: "$INTERNAL_REGISTRY/$CI_PROJECT_PATH" - CI_SERVER_URL: "http://10.10.10.10:9080" - GITLAB_FEATURES: "" - -docker-build: - stage: build - image: - name: gcr.io/kaniko-project/executor:v1.23.2-debug - entrypoint: [""] - rules: - - if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH - before_script: - - mkdir -p /kaniko/.docker - - | - cat > /kaniko/.docker/config.json < /kaniko/.docker/config.json < Date: Sat, 7 Mar 2026 11:31:25 +0000 Subject: [PATCH 32/35] CI: Fix registry to use port 9080 (same as GitLab) --- .gitlab-ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a3bb358..c5f06d3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,8 +2,10 @@ stages: - build variables: - INTERNAL_REGISTRY: "192.168.1.100:5050" + INTERNAL_REGISTRY: "192.168.1.100:9080" IMAGE_NAME: "$INTERNAL_REGISTRY/$CI_PROJECT_PATH" + CI_SERVER_URL: "http://192.168.1.100:9080" + GITLAB_FEATURES: "" docker-build: stage: build From bab4eed98f5a7af264ef8ea96b29f4289ed454dd Mon Sep 17 00:00:00 2001 From: root Date: Tue, 10 Mar 2026 17:38:19 +0100 Subject: [PATCH 33/35] Add: Forgejo CI build workflow (migrated from GitLab CI) --- .forgejo/workflows/build.yml | 63 ++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .forgejo/workflows/build.yml diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml new file mode 100644 index 0000000..0b0b08d --- /dev/null +++ b/.forgejo/workflows/build.yml @@ -0,0 +1,63 @@ +name: Build Docker Image + +on: + push: + branches: [main, nightly, feature/nightly] + +env: + REGISTRY: 192.168.1.100:3000 + IMAGE: root/jukebox-vibe + +jobs: + build: + runs-on: ubuntu-latest + container: + image: docker:latest + volumes: + - /var/run/docker.sock:/var/run/docker.sock + steps: + - uses: actions/checkout@v4 + + - name: Determine version and tag + id: vars + run: | + BRANCH="${GITHUB_REF_NAME}" + + if [ "$BRANCH" = "main" ]; then + TAG="main" + VERSION="2.0.0" + CHANNEL="stable" + elif [ "$BRANCH" = "nightly" ] || [ "$BRANCH" = "feature/nightly" ]; then + TAG="nightly" + VERSION="2.0.0-nightly" + CHANNEL="nightly" + else + TAG=$(echo "$BRANCH" | sed 's/\//-/g') + VERSION="2.0.0-dev" + CHANNEL="dev" + fi + + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT" + + - name: Build and push + run: | + docker build \ + --build-arg "VITE_BUILD_CHANNEL=${{ steps.vars.outputs.channel }}" \ + --build-arg "VITE_APP_VERSION=${{ steps.vars.outputs.version }}" \ + -t ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }} \ + -t ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }} \ + . + + if [ "${{ github.ref_name }}" = "main" ]; then + docker tag ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }} \ + ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest + fi + + echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u root --password-stdin + docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }} + docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }} + if [ "${{ github.ref_name }}" = "main" ]; then + docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest + fi From cea3ba366bb9f105d475b10f06d99b61c594d334 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 10 Mar 2026 17:46:46 +0100 Subject: [PATCH 34/35] Fix: use git clone instead of actions/checkout --- .forgejo/workflows/build.yml | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 0b0b08d..2409a01 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -16,27 +16,23 @@ jobs: volumes: - /var/run/docker.sock:/var/run/docker.sock steps: - - uses: actions/checkout@v4 + - name: Checkout + run: | + apk add --no-cache git + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "http://root:${{ secrets.REGISTRY_PASSWORD }}@192.168.1.100:3000/${GITHUB_REPOSITORY}.git" . - name: Determine version and tag id: vars run: | BRANCH="${GITHUB_REF_NAME}" - if [ "$BRANCH" = "main" ]; then - TAG="main" - VERSION="2.0.0" - CHANNEL="stable" + TAG="main"; VERSION="2.0.0"; CHANNEL="stable" elif [ "$BRANCH" = "nightly" ] || [ "$BRANCH" = "feature/nightly" ]; then - TAG="nightly" - VERSION="2.0.0-nightly" - CHANNEL="nightly" + TAG="nightly"; VERSION="2.0.0-nightly"; CHANNEL="nightly" else - TAG=$(echo "$BRANCH" | sed 's/\//-/g') - VERSION="2.0.0-dev" - CHANNEL="dev" + TAG=$(echo "$BRANCH" | sed 's/\//-/g'); VERSION="2.0.0-dev"; CHANNEL="dev" fi - echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT" @@ -49,15 +45,13 @@ jobs: -t ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }} \ -t ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }} \ . - - if [ "${{ github.ref_name }}" = "main" ]; then + if [ "${GITHUB_REF_NAME}" = "main" ]; then docker tag ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }} \ ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest fi - echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u root --password-stdin docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }} docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }} - if [ "${{ github.ref_name }}" = "main" ]; then + if [ "${GITHUB_REF_NAME}" = "main" ]; then docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest fi From b2b1dd4201c2fbce7d9a9964f55024c12b5efa9e Mon Sep 17 00:00:00 2001 From: root Date: Tue, 10 Mar 2026 19:26:49 +0100 Subject: [PATCH 35/35] Fix: use HTTPS domain for registry push [skip ci] --- .forgejo/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 2409a01..cead178 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -5,7 +5,7 @@ on: branches: [main, nightly, feature/nightly] env: - REGISTRY: 192.168.1.100:3000 + REGISTRY: forgejo.adriahub.de IMAGE: root/jukebox-vibe jobs: