Compare commits
37 commits
feature/ni
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b2b1dd4201 | |||
| cea3ba366b | |||
| bab4eed98f | |||
| c9d856bbc7 | |||
| 3f81034116 | |||
|
|
3c8ad63f99 | ||
|
|
4875747dc5 | ||
|
|
ac96896055 | ||
|
|
390d6eb575 | ||
|
|
197af92909 | ||
|
|
de67a15050 | ||
|
|
8d663f2392 | ||
|
|
852bda73f6 | ||
|
|
8598fe46aa | ||
|
|
f3b858452e | ||
|
|
149b97e511 | ||
|
|
1a1fdf69c8 | ||
|
|
761032a280 | ||
|
|
5ef5598758 | ||
|
|
546d28c8fa | ||
|
|
4884691e7d | ||
|
|
901f0bf1dd | ||
|
|
2a72f0f000 | ||
|
|
cde29698ca | ||
|
|
5d01d3b4db | ||
|
|
de57f37393 | ||
|
|
5c45c098e2 | ||
|
|
f043ff97bb | ||
|
|
c7cdf98efc | ||
|
|
531ee85b2f | ||
|
|
84bf0bea51 | ||
|
|
2e7f3bca7c | ||
|
|
a34cc7e08d | ||
|
|
8cef19f724 | ||
|
|
f26f90e0dc | ||
|
|
b0070bb972 | ||
|
|
0b849b7775 |
12 changed files with 2382 additions and 1537 deletions
57
.forgejo/workflows/build.yml
Normal file
57
.forgejo/workflows/build.yml
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
name: Build Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, nightly, feature/nightly]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: forgejo.adriahub.de
|
||||||
|
IMAGE: root/jukebox-vibe
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: docker:latest
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
steps:
|
||||||
|
- 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"
|
||||||
|
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
|
||||||
|
|
@ -2,10 +2,9 @@ stages:
|
||||||
- build
|
- build
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
REGISTRY_HOST: "10.10.10.10:5050"
|
INTERNAL_REGISTRY: "192.168.1.100:9080"
|
||||||
IMAGE_NAME: "$REGISTRY_HOST/$CI_PROJECT_PATH"
|
IMAGE_NAME: "$INTERNAL_REGISTRY/$CI_PROJECT_PATH"
|
||||||
# Force clone via IP instead of hostname to bypass Unraid Docker DNS issues
|
CI_SERVER_URL: "http://192.168.1.100:9080"
|
||||||
CI_SERVER_URL: "http://10.10.10.10:9080"
|
|
||||||
GITLAB_FEATURES: ""
|
GITLAB_FEATURES: ""
|
||||||
|
|
||||||
docker-build:
|
docker-build:
|
||||||
|
|
@ -21,7 +20,7 @@ docker-build:
|
||||||
cat > /kaniko/.docker/config.json <<EOF
|
cat > /kaniko/.docker/config.json <<EOF
|
||||||
{
|
{
|
||||||
"auths": {
|
"auths": {
|
||||||
"$REGISTRY_HOST": {
|
"$INTERNAL_REGISTRY": {
|
||||||
"username": "$CI_REGISTRY_USER",
|
"username": "$CI_REGISTRY_USER",
|
||||||
"password": "$CI_REGISTRY_PASSWORD"
|
"password": "$CI_REGISTRY_PASSWORD"
|
||||||
}
|
}
|
||||||
|
|
@ -32,16 +31,16 @@ docker-build:
|
||||||
- |
|
- |
|
||||||
if [ "$CI_COMMIT_REF_NAME" = "main" ]; then
|
if [ "$CI_COMMIT_REF_NAME" = "main" ]; then
|
||||||
TAG="main"
|
TAG="main"
|
||||||
VERSION="1.1.0"
|
VERSION="2.0.0"
|
||||||
CHANNEL="stable"
|
CHANNEL="stable"
|
||||||
elif [ "$CI_COMMIT_REF_NAME" = "feature/nightly" ] || [ "$CI_COMMIT_REF_NAME" = "nightly" ]; then
|
elif [ "$CI_COMMIT_REF_NAME" = "feature/nightly" ] || [ "$CI_COMMIT_REF_NAME" = "nightly" ]; then
|
||||||
TAG="nightly"
|
TAG="nightly"
|
||||||
VERSION="1.1.0-nightly"
|
VERSION="2.0.0-nightly"
|
||||||
CHANNEL="nightly"
|
CHANNEL="nightly"
|
||||||
else
|
else
|
||||||
CLEAN_TAG=$(echo "$CI_COMMIT_REF_NAME" | sed 's/\//-/g')
|
CLEAN_TAG=$(echo "$CI_COMMIT_REF_NAME" | sed 's/\//-/g')
|
||||||
TAG="$CLEAN_TAG"
|
TAG="$CLEAN_TAG"
|
||||||
VERSION="1.1.0-dev"
|
VERSION="2.0.0-dev"
|
||||||
CHANNEL="dev"
|
CHANNEL="dev"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -58,7 +57,7 @@ docker-build:
|
||||||
--dockerfile "$CI_PROJECT_DIR/Dockerfile" \
|
--dockerfile "$CI_PROJECT_DIR/Dockerfile" \
|
||||||
--build-arg "VITE_BUILD_CHANNEL=$CHANNEL" \
|
--build-arg "VITE_BUILD_CHANNEL=$CHANNEL" \
|
||||||
--build-arg "VITE_APP_VERSION=$VERSION" \
|
--build-arg "VITE_APP_VERSION=$VERSION" \
|
||||||
--insecure \
|
--cache=true \
|
||||||
--insecure-registry "$REGISTRY_HOST" \
|
--cache-repo="$IMAGE_NAME/cache" \
|
||||||
--skip-tls-verify-registry "$REGISTRY_HOST" \
|
--insecure-registry=$INTERNAL_REGISTRY \
|
||||||
$DESTINATIONS
|
$DESTINATIONS
|
||||||
|
|
|
||||||
31
Dockerfile
31
Dockerfile
|
|
@ -1,41 +1,51 @@
|
||||||
# Multi-stage build: Frontend (Vite) + Backend (Express + discord.js)
|
# Multi-stage build: Frontend (Vite) + Backend (Express + discord.js)
|
||||||
|
|
||||||
# --- Build frontend (npm) ---
|
# --- Build frontend (npm) ---
|
||||||
FROM node:20-slim AS web-build
|
FROM node:24-slim AS web-build
|
||||||
WORKDIR /app/web
|
WORKDIR /app/web
|
||||||
COPY web/package*.json ./
|
COPY web/package*.json ./
|
||||||
RUN npm install --no-audit --no-fund
|
RUN npm install --no-audit --no-fund
|
||||||
COPY web/ .
|
COPY web/ .
|
||||||
# Umgebungsvariable für React Build verfügbar machen (Vite liest nur VITE_*)
|
|
||||||
ARG VITE_BUILD_CHANNEL=stable
|
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_BUILD_CHANNEL=$VITE_BUILD_CHANNEL
|
||||||
ENV VITE_APP_VERSION=$VITE_APP_VERSION
|
ENV VITE_APP_VERSION=$VITE_APP_VERSION
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# --- Build server (npm) ---
|
# --- Build server (npm) ---
|
||||||
FROM node:20-slim AS server-build
|
FROM node:24-slim AS server-build
|
||||||
WORKDIR /app/server
|
WORKDIR /app/server
|
||||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||||
COPY server/package*.json ./
|
COPY server/package*.json ./
|
||||||
RUN npm install --no-audit --no-fund
|
RUN npm install --no-audit --no-fund
|
||||||
COPY server/ .
|
COPY server/ .
|
||||||
RUN npm run build
|
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
|
RUN rm -rf node_modules && npm install --omit=dev --no-audit --no-fund
|
||||||
|
|
||||||
|
# --- 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 --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 ---
|
# --- Runtime image ---
|
||||||
FROM node:20-slim AS runtime
|
FROM node:24-slim AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=8080
|
ENV PORT=8080
|
||||||
ENV SOUNDS_DIR=/data/sounds
|
ENV SOUNDS_DIR=/data/sounds
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y ffmpeg curl ca-certificates && 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 \
|
&& 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 \
|
&& chmod a+rx /usr/local/bin/yt-dlp \
|
||||||
&& yt-dlp --version || true
|
&& apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=ffmpeg-fetch /tmp/ffmpeg/ffmpeg /usr/local/bin/ffmpeg
|
||||||
COPY --from=server-build /app/server/dist ./server/dist
|
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/node_modules ./server/node_modules
|
||||||
COPY --from=server-build /app/server/package.json ./server/package.json
|
COPY --from=server-build /app/server/package.json ./server/package.json
|
||||||
|
|
@ -44,6 +54,3 @@ COPY --from=web-build /app/web/dist ./web/dist
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
VOLUME ["/data/sounds"]
|
VOLUME ["/data/sounds"]
|
||||||
CMD ["node", "server/dist/index.js"]
|
CMD ["node", "server/dist/index.js"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
456
README.md
456
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.
|
||||||
|
|
||||||

|
## 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)
|
## Features
|
||||||
- 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)
|
|
||||||
|
|
||||||
## 🚀 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
|
### Discord Bot
|
||||||
- Docker & Docker Compose
|
- **Entrance Sounds** – persoenlicher Sound beim Channel-Join
|
||||||
- Discord bot token with intents: `Guilds`, `GuildVoiceStates`, `DirectMessages`
|
- **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
|
```bash
|
||||||
# Clone repository
|
git clone https://git.daddelolymp.de/root/jukebox-vibe.git
|
||||||
git clone https://github.com/flex420/jukebox-vibe.git
|
|
||||||
cd jukebox-vibe
|
cd jukebox-vibe
|
||||||
|
|
||||||
# Create .env
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
# .env anpassen (DISCORD_TOKEN, ADMIN_PWD)
|
||||||
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Configuration
|
### Docker Run (ohne Compose)
|
||||||
```env
|
|
||||||
# Edit the .env file
|
|
||||||
DISCORD_TOKEN=your_discord_bot_token_here
|
|
||||||
ADMIN_PWD=choose-a-strong-password
|
|
||||||
PORT=8080
|
|
||||||
SOUNDS_DIR=/data/sounds
|
|
||||||
|
|
||||||
# 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 <file.mp3|file.wav> | remove` – set or remove your entrance sound
|
|
||||||
- `?exit <file.mp3|file.wav> | 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
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name jukebox-420 \
|
--name jukebox \
|
||||||
-p 8199:8080 \
|
-p 8199:8080 \
|
||||||
--env-file .env \
|
-e DISCORD_TOKEN=dein_token \
|
||||||
-v $(pwd)/data/sounds:/data/sounds \
|
-e ADMIN_PWD=dein_passwort \
|
||||||
flex420/jukebox-vibe:latest
|
-v $(pwd)/sounds:/data/sounds \
|
||||||
|
--restart unless-stopped \
|
||||||
|
git.daddelolymp.de/root/jukebox-vibe:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### **Docker Hub**
|
## Konfiguration
|
||||||
```bash
|
|
||||||
# Image pullen
|
|
||||||
docker pull flex420/jukebox-vibe:latest
|
|
||||||
|
|
||||||
# Container starten
|
| Variable | Default | Beschreibung |
|
||||||
docker run -d --name jukebox-420 -p 8199:8080 --env-file .env -v $(pwd)/data/sounds:/data/sounds flex420/jukebox-vibe:latest
|
|---|---|---|
|
||||||
|
| `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 <datei>` | Entrance-Sound setzen |
|
||||||
|
| `?entrance remove` | Entrance-Sound entfernen |
|
||||||
|
| `?exit <datei>` | 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.
|
POST /api/admin/login Login (body: password)
|
||||||
- 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/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/
|
jukebox-vibe/
|
||||||
├── server/ # Backend (Node.js/Express)
|
server/
|
||||||
│ ├── src/
|
src/index.ts # Server + Discord Bot (Hauptdatei)
|
||||||
│ │ ├── index.ts # Main Server + Discord Bot
|
package.json
|
||||||
│ │ └── types/ # TypeScript Definitions
|
tsconfig.json
|
||||||
│ ├── package.json
|
web/
|
||||||
│ └── tsconfig.json
|
src/
|
||||||
├── web/ # Frontend (React/Vite)
|
App.tsx # React SPA
|
||||||
│ ├── src/
|
styles.css # Themes + Layout
|
||||||
│ │ ├── App.tsx # Main React Component
|
types.ts # TypeScript Types
|
||||||
│ │ ├── api.ts # API Client
|
package.json
|
||||||
│ │ ├── styles.css # Theme Styles
|
vite.config.ts
|
||||||
│ │ └── types.ts # TypeScript Types
|
Dockerfile # Multi-Stage Build (web + server + runtime)
|
||||||
│ ├── package.json
|
docker-compose.yml
|
||||||
│ └── index.html
|
.gitlab-ci.yml # CI/CD Pipeline (Kaniko)
|
||||||
├── docker-compose.yml # Docker Compose Config
|
.env.example
|
||||||
├── Dockerfile # Multi-Stage Build
|
|
||||||
├── .env.example # Environment Template
|
|
||||||
└── README.md # Diese Datei
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔧 Development
|
## CI/CD
|
||||||
|
|
||||||
### **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! 🚀
|
|
||||||
|
|
||||||
|
GitLab CI baut automatisch bei jedem Push:
|
||||||
|
|
||||||
|
| Branch | Image Tag |
|
||||||
|
|---|---|
|
||||||
|
| `main` | `:main`, `:latest`, `:sha` |
|
||||||
|
| `nightly` | `:nightly`, `:sha` |
|
||||||
|
| andere | `:<branch-name>`, `:sha` |
|
||||||
|
|
||||||
|
Registry: `git.daddelolymp.de/root/jukebox-vibe`
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "discord-soundboard-server",
|
"name": "discord-soundboard-server",
|
||||||
"version": "1.1.1",
|
"version": "2.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|
@ -10,23 +10,24 @@
|
||||||
"start": "node dist/index.js"
|
"start": "node dist/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discordjs/opus": "^0.9.0",
|
"@discordjs/opus": "^0.10.0",
|
||||||
"@discordjs/voice": "^0.18.0",
|
"@snazzah/davey": "^0.1.10",
|
||||||
|
"@discordjs/voice": "^0.19.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"discord.js": "^14.16.3",
|
"discord.js": "^14.25.1",
|
||||||
"express": "^4.19.2",
|
"express": "^5.2.1",
|
||||||
"libsodium-wrappers": "^0.8.2",
|
"libsodium-wrappers": "^0.8.2",
|
||||||
"multer": "^2.0.0",
|
"multer": "^2.0.0",
|
||||||
"sodium-native": "^4.0.8",
|
"sodium-native": "^5.0.10",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^5.0.6",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^20.12.12",
|
"@types/node": "^24.0.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import express, { Request, Response } from 'express';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import crypto from 'node:crypto';
|
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 {
|
import {
|
||||||
joinVoiceChannel,
|
joinVoiceChannel,
|
||||||
createAudioPlayer,
|
createAudioPlayer,
|
||||||
|
|
@ -22,9 +22,8 @@ import {
|
||||||
} from '@discordjs/voice';
|
} from '@discordjs/voice';
|
||||||
import sodium from 'libsodium-wrappers';
|
import sodium from 'libsodium-wrappers';
|
||||||
import nacl from 'tweetnacl';
|
import nacl from 'tweetnacl';
|
||||||
// Streaming externer Plattformen entfernt – nur MP3-URLs werden noch unterstützt
|
|
||||||
import child_process from 'node:child_process';
|
import child_process from 'node:child_process';
|
||||||
import { PassThrough } from 'node:stream';
|
import { PassThrough, Readable } from 'node:stream';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
@ -46,6 +45,187 @@ if (!DISCORD_TOKEN) {
|
||||||
|
|
||||||
fs.mkdirSync(SOUNDS_DIR, { recursive: true });
|
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
|
// Persistenter Zustand: Lautstärke/Plays + Kategorien
|
||||||
type Category = { id: string; name: string; color?: string; sort?: number };
|
type Category = { id: string; name: string; color?: string; sort?: number };
|
||||||
type PersistedState = {
|
type PersistedState = {
|
||||||
|
|
@ -158,6 +338,36 @@ const NORMALIZE_TP = String(process.env.NORMALIZE_TP ?? '-1.5');
|
||||||
const NORM_CACHE_DIR = path.join(SOUNDS_DIR, '.norm-cache');
|
const NORM_CACHE_DIR = path.join(SOUNDS_DIR, '.norm-cache');
|
||||||
fs.mkdirSync(NORM_CACHE_DIR, { recursive: true });
|
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<string, Buffer>();
|
||||||
|
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;
|
||||||
|
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);
|
||||||
|
pcmMemoryCache.set(cachedPath, data);
|
||||||
|
pcmMemoryCacheBytes += data.byteLength;
|
||||||
|
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) */
|
/** Erzeugt einen Cache-Key aus dem relativen Pfad (Slash-normalisiert, Sonderzeichen escaped) */
|
||||||
function normCacheKey(filePath: string): string {
|
function normCacheKey(filePath: string): string {
|
||||||
const rel = path.relative(SOUNDS_DIR, filePath).replace(/\\/g, '/');
|
const rel = path.relative(SOUNDS_DIR, filePath).replace(/\\/g, '/');
|
||||||
|
|
@ -172,7 +382,7 @@ function getNormCachePath(filePath: string): string | null {
|
||||||
try {
|
try {
|
||||||
const srcMtime = fs.statSync(filePath).mtimeMs;
|
const srcMtime = fs.statSync(filePath).mtimeMs;
|
||||||
const cacheMtime = fs.statSync(cacheFile).mtimeMs;
|
const cacheMtime = fs.statSync(cacheFile).mtimeMs;
|
||||||
if (srcMtime > cacheMtime) { try { fs.unlinkSync(cacheFile); } catch {} return null; }
|
if (srcMtime > cacheMtime) { try { fs.unlinkSync(cacheFile); } catch {} invalidatePcmMemory(cacheFile); return null; }
|
||||||
} catch { return null; }
|
} catch { return null; }
|
||||||
return cacheFile;
|
return cacheFile;
|
||||||
}
|
}
|
||||||
|
|
@ -291,6 +501,8 @@ const partyTimers = new Map<string, NodeJS.Timeout>();
|
||||||
const partyActive = new Set<string>();
|
const partyActive = new Set<string>();
|
||||||
// Now-Playing: aktuell gespielter Sound pro Guild
|
// Now-Playing: aktuell gespielter Sound pro Guild
|
||||||
const nowPlaying = new Map<string, string>();
|
const nowPlaying = new Map<string, string>();
|
||||||
|
// Verbindungszeitpunkt pro Guild (fuer Uptime-Anzeige im Frontend)
|
||||||
|
const connectedSince = new Map<string, string>();
|
||||||
// SSE-Klienten für Broadcasts (z.B. Partymode Status)
|
// SSE-Klienten für Broadcasts (z.B. Partymode Status)
|
||||||
const sseClients = new Set<Response>();
|
const sseClients = new Set<Response>();
|
||||||
function sseBroadcast(payload: any) {
|
function sseBroadcast(payload: any) {
|
||||||
|
|
@ -381,9 +593,19 @@ async function playFilePath(guildId: string, channelId: string, filePath: string
|
||||||
if (NORMALIZE_ENABLE) {
|
if (NORMALIZE_ENABLE) {
|
||||||
const cachedPath = getNormCachePath(filePath);
|
const cachedPath = getNormCachePath(filePath);
|
||||||
if (cachedPath) {
|
if (cachedPath) {
|
||||||
// Cache-Hit: gecachte PCM-Datei als Stream lesen (kein ffmpeg, instant)
|
// Cache-Hit: PCM aus RAM lesen (kein Disk-I/O, instant)
|
||||||
const pcmStream = fs.createReadStream(cachedPath);
|
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 });
|
resource = createAudioResource(pcmStream, { inlineVolume: true, inputType: StreamType.Raw });
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Cache-Miss: ffmpeg streamen (sofortige Wiedergabe) UND gleichzeitig in Cache schreiben
|
// Cache-Miss: ffmpeg streamen (sofortige Wiedergabe) UND gleichzeitig in Cache schreiben
|
||||||
const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath));
|
const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath));
|
||||||
|
|
@ -402,6 +624,14 @@ async function playFilePath(guildId: string, channelId: string, filePath: string
|
||||||
playerStream.end();
|
playerStream.end();
|
||||||
cacheWrite.end();
|
cacheWrite.end();
|
||||||
console.log(`${new Date().toISOString()} | Loudnorm cached: ${path.basename(filePath)}`);
|
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('error', () => { try { fs.unlinkSync(cacheFile); } catch {} });
|
||||||
ff.on('close', (code) => {
|
ff.on('close', (code) => {
|
||||||
|
|
@ -518,26 +748,29 @@ async function handleCommand(message: Message, content: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureConnectionReady(connection: VoiceConnection, channelId: string, guildId: string, guild: any): Promise<VoiceConnection> {
|
async function ensureConnectionReady(connection: VoiceConnection, channelId: string, guildId: string, guild: any): Promise<VoiceConnection> {
|
||||||
|
// Versuch 1: Warten ob bestehende Connection ready wird
|
||||||
try {
|
try {
|
||||||
await entersState(connection, VoiceConnectionStatus.Ready, 15_000);
|
await entersState(connection, VoiceConnectionStatus.Ready, 15_000);
|
||||||
console.log(`${new Date().toISOString()} | VoiceConnection ready`);
|
console.log(`${new Date().toISOString()} | VoiceConnection ready`);
|
||||||
return connection;
|
return connection;
|
||||||
} catch (e) {
|
} 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 {
|
try {
|
||||||
connection.rejoin({ channelId, selfDeaf: false, selfMute: false });
|
connection.rejoin({ channelId, selfDeaf: false, selfMute: false });
|
||||||
await entersState(connection, VoiceConnectionStatus.Ready, 15_000);
|
await entersState(connection, VoiceConnectionStatus.Ready, 15_000);
|
||||||
console.log(`${new Date().toISOString()} | VoiceConnection ready after rejoin`);
|
console.log(`${new Date().toISOString()} | VoiceConnection ready after rejoin`);
|
||||||
return connection;
|
return connection;
|
||||||
} catch (e2) {
|
} 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 {
|
// Versuch 3: Komplett neu verbinden
|
||||||
connection.destroy();
|
try { connection.destroy(); } catch {}
|
||||||
} catch {}
|
guildAudioState.delete(guildId);
|
||||||
|
|
||||||
const newConn = joinVoiceChannel({
|
const newConn = joinVoiceChannel({
|
||||||
channelId,
|
channelId,
|
||||||
guildId,
|
guildId,
|
||||||
|
|
@ -545,10 +778,16 @@ async function ensureConnectionReady(connection: VoiceConnection, channelId: str
|
||||||
selfMute: false,
|
selfMute: false,
|
||||||
selfDeaf: false
|
selfDeaf: false
|
||||||
});
|
});
|
||||||
await entersState(newConn, VoiceConnectionStatus.Ready, 15_000).catch((e3) => {
|
try {
|
||||||
console.error(`${new Date().toISOString()} | VoiceConnection not ready after fresh join`, e3);
|
await entersState(newConn, VoiceConnectionStatus.Ready, 15_000);
|
||||||
});
|
console.log(`${new Date().toISOString()} | VoiceConnection ready after fresh join`);
|
||||||
return newConn;
|
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) {
|
function attachVoiceLifecycle(state: GuildAudioState, guild: any) {
|
||||||
|
|
@ -556,8 +795,26 @@ function attachVoiceLifecycle(state: GuildAudioState, guild: any) {
|
||||||
// Mehrfach-Registrierung verhindern
|
// Mehrfach-Registrierung verhindern
|
||||||
if ((connection as any).__lifecycleAttached) return;
|
if ((connection as any).__lifecycleAttached) return;
|
||||||
try { (connection as any).setMaxListeners?.(0); } catch {}
|
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) => {
|
connection.on('stateChange', async (oldS: any, newS: any) => {
|
||||||
console.log(`${new Date().toISOString()} | VoiceConnection: ${oldS.status} -> ${newS.status}`);
|
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 {
|
try {
|
||||||
if (newS.status === VoiceConnectionStatus.Disconnected) {
|
if (newS.status === VoiceConnectionStatus.Disconnected) {
|
||||||
// Versuche, die Verbindung kurzfristig neu auszuhandeln, sonst rejoin
|
// Versuche, die Verbindung kurzfristig neu auszuhandeln, sonst rejoin
|
||||||
|
|
@ -567,9 +824,28 @@ function attachVoiceLifecycle(state: GuildAudioState, guild: any) {
|
||||||
entersState(connection, VoiceConnectionStatus.Connecting, 5_000)
|
entersState(connection, VoiceConnectionStatus.Connecting, 5_000)
|
||||||
]);
|
]);
|
||||||
} catch {
|
} 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 });
|
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) {
|
} else if (newS.status === VoiceConnectionStatus.Destroyed) {
|
||||||
|
connectedSince.delete(state.guildId);
|
||||||
// Komplett neu beitreten
|
// Komplett neu beitreten
|
||||||
const newConn = joinVoiceChannel({
|
const newConn = joinVoiceChannel({
|
||||||
channelId: state.channelId,
|
channelId: state.channelId,
|
||||||
|
|
@ -582,14 +858,38 @@ function attachVoiceLifecycle(state: GuildAudioState, guild: any) {
|
||||||
newConn.subscribe(state.player);
|
newConn.subscribe(state.player);
|
||||||
attachVoiceLifecycle(state, guild);
|
attachVoiceLifecycle(state, guild);
|
||||||
} else if (newS.status === VoiceConnectionStatus.Connecting || newS.status === VoiceConnectionStatus.Signalling) {
|
} else if (newS.status === VoiceConnectionStatus.Connecting || newS.status === VoiceConnectionStatus.Signalling) {
|
||||||
|
isReconnecting = true;
|
||||||
try {
|
try {
|
||||||
await entersState(connection, VoiceConnectionStatus.Ready, 15_000);
|
await entersState(connection, VoiceConnectionStatus.Ready, 15_000);
|
||||||
|
// Ready wird oben im Handler behandelt
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`${new Date().toISOString()} | Voice not ready from ${newS.status}, rejoin`, 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 });
|
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) {
|
} catch (e) {
|
||||||
|
isReconnecting = false;
|
||||||
console.error(`${new Date().toISOString()} | Voice lifecycle handler error`, e);
|
console.error(`${new Date().toISOString()} | Voice lifecycle handler error`, e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -1171,14 +1471,17 @@ app.get('/api/channels', (_req: Request, res: Response) => {
|
||||||
if (!client.isReady()) return res.status(503).json({ error: 'Bot noch nicht bereit' });
|
if (!client.isReady()) return res.status(503).json({ error: 'Bot noch nicht bereit' });
|
||||||
|
|
||||||
const allowed = new Set(ALLOWED_GUILD_IDS);
|
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) {
|
for (const [, guild] of client.guilds.cache) {
|
||||||
if (allowed.size > 0 && !allowed.has(guild.id)) continue;
|
if (allowed.size > 0 && !allowed.has(guild.id)) continue;
|
||||||
const channels = guild.channels.cache;
|
const channels = guild.channels.cache;
|
||||||
for (const [, ch] of channels) {
|
for (const [, ch] of channels) {
|
||||||
if (ch?.type === ChannelType.GuildVoice || ch?.type === ChannelType.GuildStageVoice) {
|
if (ch?.type === ChannelType.GuildVoice || ch?.type === ChannelType.GuildStageVoice) {
|
||||||
const sel = getSelectedChannelForGuild(guild.id);
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1405,7 +1708,22 @@ app.get('/api/events', (req: Request, res: Response) => {
|
||||||
|
|
||||||
// Snapshot senden
|
// Snapshot senden
|
||||||
try {
|
try {
|
||||||
res.write(`data: ${JSON.stringify({ type: 'snapshot', party: Array.from(partyActive), selected: persistedState.selectedChannels ?? {}, volumes: persistedState.volumes ?? {}, nowplaying: Object.fromEntries(nowPlaying) })}\n\n`);
|
const statsSnap: Record<string, any> = {};
|
||||||
|
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 {}
|
} catch {}
|
||||||
|
|
||||||
// Ping, damit Proxies die Verbindung offen halten
|
// Ping, damit Proxies die Verbindung offen halten
|
||||||
|
|
@ -1419,59 +1737,105 @@ app.get('/api/events', (req: Request, res: Response) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Medien-URL abspielen ---
|
// --- Medien-URL abspielen (YouTube / Instagram / MP3) ---
|
||||||
// Unterstützt: direkte MP3-URL (Download und Ablage)
|
|
||||||
app.post('/api/play-url', async (req: Request, res: Response) => {
|
app.post('/api/play-url', async (req: Request, res: Response) => {
|
||||||
|
const startTime = Date.now();
|
||||||
try {
|
try {
|
||||||
const { url, guildId, channelId, volume } = req.body as { url?: string; guildId?: string; channelId?: string; volume?: number };
|
const { url, guildId, channelId, volume, filename } = req.body as { url?: string; guildId?: string; channelId?: string; volume?: number; filename?: string };
|
||||||
if (!url || !guildId || !channelId) return res.status(400).json({ error: 'url, guildId, channelId erforderlich' });
|
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;
|
if (!url || !guildId || !channelId) return res.status(400).json({ error: 'url, guildId, channelId erforderlich' });
|
||||||
try {
|
try { new URL(url); } catch { return res.status(400).json({ error: 'Ungültige URL' }); }
|
||||||
parsed = new URL(url);
|
if (!isSupportedUrl(url)) return res.status(400).json({ error: 'Nur YouTube, Instagram oder direkte MP3-Links' });
|
||||||
} catch {
|
|
||||||
return res.status(400).json({ error: 'Ungültige URL' });
|
const { savedFile, savedPath } = await handleUrlDownload(url, filename);
|
||||||
}
|
|
||||||
const pathname = parsed.pathname.toLowerCase();
|
try { await playFilePath(guildId, channelId, savedPath, volume, savedFile); console.log(`[Jukebox] [play-url] playing`); }
|
||||||
if (!pathname.endsWith('.mp3')) {
|
catch (e: any) { console.error(`[Jukebox] [play-url] play failed (file saved): ${e?.message}`); }
|
||||||
return res.status(400).json({ error: 'Nur direkte MP3-Links werden unterstützt.' });
|
|
||||||
}
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
const fileName = path.basename(parsed.pathname);
|
console.log(`[Jukebox] [play-url] ✓ DONE in ${elapsed}s → ${savedFile}`);
|
||||||
const dest = path.join(SOUNDS_DIR, fileName);
|
return res.json({ ok: true, saved: savedFile });
|
||||||
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) {
|
} 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' });
|
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)
|
// Static Frontend ausliefern (Vite build)
|
||||||
const webDistPath = path.resolve(__dirname, '../../web/dist');
|
const webDistPath = path.resolve(__dirname, '../../web/dist');
|
||||||
if (fs.existsSync(webDistPath)) {
|
if (fs.existsSync(webDistPath)) {
|
||||||
app.use(express.static(webDistPath));
|
app.use(express.static(webDistPath));
|
||||||
app.get('*', (_req, res) => {
|
app.get('/{*splat}', (_req, res) => {
|
||||||
res.sendFile(path.join(webDistPath, 'index.html'));
|
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, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Server läuft auf http://0.0.0.0:${PORT}`);
|
console.log(`Server läuft auf http://0.0.0.0:${PORT}`);
|
||||||
|
|
||||||
// Vollständige Cache-Synchronisation beim Start (Hintergrund)
|
// Vollständige Cache-Synchronisation beim Start (Hintergrund)
|
||||||
syncNormCache();
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<meta name="theme-color" content="#0b0b0f" />
|
<meta name="theme-color" content="#0b0b0f" />
|
||||||
<title>Jukebox</title>
|
<title>Jukebox420</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "discord-soundboard-web",
|
"name": "discord-soundboard-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.1",
|
"version": "2.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|
|
||||||
308
web/src/App.tsx
308
web/src/App.tsx
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
fetchChannels, fetchSounds, fetchAnalytics, playSound, playUrl, setVolumeLive, getVolume,
|
fetchChannels, fetchSounds, fetchAnalytics, playSound, playUrl, downloadUrl, setVolumeLive, getVolume,
|
||||||
adminStatus, adminLogin, adminLogout, adminDelete, adminRename,
|
adminStatus, adminLogin, adminLogout, adminDelete, adminRename,
|
||||||
fetchCategories, partyStart, partyStop, subscribeEvents,
|
fetchCategories, partyStart, partyStop, subscribeEvents,
|
||||||
getSelectedChannels, setSelectedChannel, uploadFile,
|
getSelectedChannels, setSelectedChannel, uploadFile,
|
||||||
|
|
@ -52,6 +52,13 @@ export default function App() {
|
||||||
const [importUrl, setImportUrl] = useState('');
|
const [importUrl, setImportUrl] = useState('');
|
||||||
const [importBusy, setImportBusy] = useState(false);
|
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 ── */
|
/* ── Channels ── */
|
||||||
const [channels, setChannels] = useState<VoiceChannelInfo[]>([]);
|
const [channels, setChannels] = useState<VoiceChannelInfo[]>([]);
|
||||||
const [selected, setSelected] = useState('');
|
const [selected, setSelected] = useState('');
|
||||||
|
|
@ -91,6 +98,17 @@ export default function App() {
|
||||||
const dragCounterRef = useRef(0);
|
const dragCounterRef = useRef(0);
|
||||||
const uploadDismissRef = useRef<ReturnType<typeof setTimeout>>();
|
const uploadDismissRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
/* ── Voice Stats ── */
|
||||||
|
interface VoiceStats {
|
||||||
|
voicePing: number | null;
|
||||||
|
gatewayPing: number;
|
||||||
|
status: string;
|
||||||
|
channelName: string | null;
|
||||||
|
connectedSince: string | null;
|
||||||
|
}
|
||||||
|
const [voiceStats, setVoiceStats] = useState<VoiceStats | null>(null);
|
||||||
|
const [showConnModal, setShowConnModal] = useState(false);
|
||||||
|
|
||||||
/* ── UI ── */
|
/* ── UI ── */
|
||||||
const [notification, setNotification] = useState<{ msg: string; type: 'info' | 'error' } | null>(null);
|
const [notification, setNotification] = useState<{ msg: string; type: 'info' | 'error' } | null>(null);
|
||||||
const [clock, setClock] = useState('');
|
const [clock, setClock] = useState('');
|
||||||
|
|
@ -142,14 +160,35 @@ export default function App() {
|
||||||
setTimeout(() => setNotification(null), 3000);
|
setTimeout(() => setNotification(null), 3000);
|
||||||
}, []);
|
}, []);
|
||||||
const soundKey = useCallback((s: Sound) => s.relativePath ?? s.fileName, []);
|
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 {
|
try {
|
||||||
const parsed = new URL(value.trim());
|
const parsed = new URL(normalizeUrl(value));
|
||||||
return parsed.pathname.toLowerCase().endsWith('.mp3');
|
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 {
|
} catch {
|
||||||
return false;
|
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 guildId = selected ? selected.split(':')[0] : '';
|
||||||
const channelId = selected ? selected.split(':')[1] : '';
|
const channelId = selected ? selected.split(':')[1] : '';
|
||||||
|
|
@ -233,6 +272,11 @@ export default function App() {
|
||||||
const g = selectedRef.current?.split(':')[0];
|
const g = selectedRef.current?.split(':')[0];
|
||||||
if (g && typeof np[g] === 'string') setLastPlayed(np[g]);
|
if (g && typeof np[g] === 'string') setLastPlayed(np[g]);
|
||||||
} catch { }
|
} 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') {
|
} else if (msg?.type === 'channel') {
|
||||||
const g = selectedRef.current?.split(':')[0];
|
const g = selectedRef.current?.split(':')[0];
|
||||||
if (msg.guildId === g) setSelected(`${msg.guildId}:${msg.channelId}`);
|
if (msg.guildId === g) setSelected(`${msg.guildId}:${msg.channelId}`);
|
||||||
|
|
@ -242,6 +286,17 @@ export default function App() {
|
||||||
} else if (msg?.type === 'nowplaying') {
|
} else if (msg?.type === 'nowplaying') {
|
||||||
const g = selectedRef.current?.split(':')[0];
|
const g = selectedRef.current?.split(':')[0];
|
||||||
if (msg.guildId === g) setLastPlayed(msg.name || '');
|
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 { } };
|
return () => { try { unsub(); } catch { } };
|
||||||
|
|
@ -319,22 +374,42 @@ export default function App() {
|
||||||
} catch (e: any) { notify(e?.message || 'Play fehlgeschlagen', 'error'); }
|
} catch (e: any) { notify(e?.message || 'Play fehlgeschlagen', 'error'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUrlImport() {
|
// Open download modal instead of downloading directly
|
||||||
const trimmed = importUrl.trim();
|
function handleUrlImport() {
|
||||||
if (!trimmed) return notify('Bitte einen MP3-Link eingeben', 'error');
|
const trimmed = normalizeUrl(importUrl);
|
||||||
if (!selected) return notify('Bitte einen Voice-Channel auswählen', 'error');
|
if (!trimmed) return notify('Bitte einen Link eingeben', 'error');
|
||||||
if (!isMp3Url(trimmed)) return notify('Nur direkte MP3-Links sind erlaubt', 'error');
|
if (!isSupportedUrl(trimmed)) return notify('Nur YouTube, Instagram oder direkte MP3-Links', 'error');
|
||||||
setImportBusy(true);
|
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 {
|
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('');
|
setImportUrl('');
|
||||||
notify('MP3 importiert und abgespielt');
|
|
||||||
setRefreshKey(k => k + 1);
|
setRefreshKey(k => k + 1);
|
||||||
await loadAnalytics();
|
void loadAnalytics();
|
||||||
|
// Auto-close after 2.5s
|
||||||
|
setTimeout(() => setDlModal(null), 2500);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
notify(e?.message || 'URL-Import fehlgeschlagen', 'error');
|
setDlModal(prev => prev ? { ...prev, phase: 'error', error: e?.message || 'Fehler' } : null);
|
||||||
} finally {
|
|
||||||
setImportBusy(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -573,7 +648,7 @@ export default function App() {
|
||||||
<div className="app-logo">
|
<div className="app-logo">
|
||||||
<span className="material-icons" style={{ fontSize: 16, color: 'white' }}>music_note</span>
|
<span className="material-icons" style={{ fontSize: 16, color: 'white' }}>music_note</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="app-title">Soundboard</span>
|
<span className="app-title">Jukebox420</span>
|
||||||
|
|
||||||
{/* Channel Dropdown */}
|
{/* Channel Dropdown */}
|
||||||
<div className="channel-dropdown" onClick={e => e.stopPropagation()}>
|
<div className="channel-dropdown" onClick={e => e.stopPropagation()}>
|
||||||
|
|
@ -583,7 +658,7 @@ export default function App() {
|
||||||
>
|
>
|
||||||
<span className="material-icons cb-icon">headset</span>
|
<span className="material-icons cb-icon">headset</span>
|
||||||
{selected && <span className="channel-status" />}
|
{selected && <span className="channel-status" />}
|
||||||
<span className="channel-label">{selectedChannel?.channelName || 'Channel...'}</span>
|
<span className="channel-label">{selectedChannel ? `${selectedChannel.channelName}${selectedChannel.members ? ` (${selectedChannel.members})` : ''}` : 'Channel...'}</span>
|
||||||
<span className={`material-icons chevron`}>expand_more</span>
|
<span className={`material-icons chevron`}>expand_more</span>
|
||||||
</button>
|
</button>
|
||||||
{channelOpen && (
|
{channelOpen && (
|
||||||
|
|
@ -598,7 +673,7 @@ export default function App() {
|
||||||
onClick={() => handleChannelSelect(ch)}
|
onClick={() => handleChannelSelect(ch)}
|
||||||
>
|
>
|
||||||
<span className="material-icons co-icon">volume_up</span>
|
<span className="material-icons co-icon">volume_up</span>
|
||||||
{ch.channelName}
|
{ch.channelName}{ch.members ? ` (${ch.members})` : ''}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|
@ -624,13 +699,16 @@ export default function App() {
|
||||||
<div className="np-wave-bar" /><div className="np-wave-bar" />
|
<div className="np-wave-bar" /><div className="np-wave-bar" />
|
||||||
<div className="np-wave-bar" /><div className="np-wave-bar" />
|
<div className="np-wave-bar" /><div className="np-wave-bar" />
|
||||||
</div>
|
</div>
|
||||||
<span className="np-name">{lastPlayed}</span>
|
<span className="np-label">Last Played:</span> <span className="np-name">{lastPlayed}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selected && (
|
{selected && (
|
||||||
<div className="connection">
|
<div className="connection" onClick={() => setShowConnModal(true)} style={{cursor:'pointer'}} title="Verbindungsdetails">
|
||||||
<span className="conn-dot" />
|
<span className="conn-dot" />
|
||||||
Verbunden
|
Verbunden
|
||||||
|
{voiceStats?.voicePing != null && (
|
||||||
|
<span className="conn-ping">{voiceStats.voicePing}ms</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|
@ -685,20 +763,32 @@ export default function App() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="url-import-wrap">
|
<div className="url-import-wrap">
|
||||||
<span className="material-icons url-import-icon">link</span>
|
<span className="material-icons url-import-icon">
|
||||||
|
{getUrlType(importUrl) === 'youtube' ? 'smart_display'
|
||||||
|
: getUrlType(importUrl) === 'instagram' ? 'photo_camera'
|
||||||
|
: 'link'}
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
className="url-import-input"
|
className="url-import-input"
|
||||||
type="url"
|
type="text"
|
||||||
placeholder="MP3-URL einfügen..."
|
placeholder="YouTube / Instagram / MP3-Link..."
|
||||||
value={importUrl}
|
value={importUrl}
|
||||||
onChange={e => setImportUrl(e.target.value)}
|
onChange={e => setImportUrl(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') void handleUrlImport(); }}
|
onKeyDown={e => { if (e.key === 'Enter') void handleUrlImport(); }}
|
||||||
/>
|
/>
|
||||||
|
{importUrl && (
|
||||||
|
<span className={`url-import-tag ${isSupportedUrl(importUrl) ? 'valid' : 'invalid'}`}>
|
||||||
|
{getUrlType(importUrl) === 'youtube' ? 'YT'
|
||||||
|
: getUrlType(importUrl) === 'instagram' ? 'IG'
|
||||||
|
: getUrlType(importUrl) === 'mp3' ? 'MP3'
|
||||||
|
: '?'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="url-import-btn"
|
className="url-import-btn"
|
||||||
onClick={() => { void handleUrlImport(); }}
|
onClick={() => { void handleUrlImport(); }}
|
||||||
disabled={importBusy}
|
disabled={importBusy || (!!importUrl && !isSupportedUrl(importUrl))}
|
||||||
title="MP3 importieren"
|
title="Sound herunterladen"
|
||||||
>
|
>
|
||||||
{importBusy ? 'Lädt...' : 'Download'}
|
{importBusy ? 'Lädt...' : 'Download'}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -948,6 +1038,66 @@ export default function App() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ═══ 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 (
|
||||||
|
<div className="conn-modal-overlay" onClick={() => setShowConnModal(false)}>
|
||||||
|
<div className="conn-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="conn-modal-header">
|
||||||
|
<span className="material-icons" style={{fontSize:20,color:'var(--green)'}}>cell_tower</span>
|
||||||
|
<span>Verbindungsdetails</span>
|
||||||
|
<button className="conn-modal-close" onClick={() => setShowConnModal(false)}>
|
||||||
|
<span className="material-icons">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="conn-modal-body">
|
||||||
|
<div className="conn-stat">
|
||||||
|
<span className="conn-stat-label">Voice Ping</span>
|
||||||
|
<span className="conn-stat-value">
|
||||||
|
<span className="conn-ping-dot" style={{background: pingColor(voiceStats.voicePing)}} />
|
||||||
|
{voiceStats.voicePing != null ? `${voiceStats.voicePing} ms` : '---'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="conn-stat">
|
||||||
|
<span className="conn-stat-label">Gateway Ping</span>
|
||||||
|
<span className="conn-stat-value">
|
||||||
|
<span className="conn-ping-dot" style={{background: pingColor(voiceStats.gatewayPing)}} />
|
||||||
|
{voiceStats.gatewayPing >= 0 ? `${voiceStats.gatewayPing} ms` : '---'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="conn-stat">
|
||||||
|
<span className="conn-stat-label">Status</span>
|
||||||
|
<span className="conn-stat-value" style={{color: voiceStats.status === 'ready' ? 'var(--green)' : '#f0a830'}}>
|
||||||
|
{voiceStats.status === 'ready' ? 'Verbunden' : voiceStats.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="conn-stat">
|
||||||
|
<span className="conn-stat-label">Kanal</span>
|
||||||
|
<span className="conn-stat-value">{voiceStats.channelName || '---'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="conn-stat">
|
||||||
|
<span className="conn-stat-label">Verbunden seit</span>
|
||||||
|
<span className="conn-stat-value">{uptimeStr}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* ═══ TOAST ═══ */}
|
{/* ═══ TOAST ═══ */}
|
||||||
{notification && (
|
{notification && (
|
||||||
<div className={`toast ${notification.type}`}>
|
<div className={`toast ${notification.type}`}>
|
||||||
|
|
@ -1162,6 +1312,110 @@ export default function App() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Download Modal ── */}
|
||||||
|
{dlModal && (
|
||||||
|
<div className="dl-modal-overlay" onClick={() => dlModal.phase !== 'downloading' && setDlModal(null)}>
|
||||||
|
<div className="dl-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="dl-modal-header">
|
||||||
|
<span className="material-icons" style={{ fontSize: 20 }}>
|
||||||
|
{dlModal.type === 'youtube' ? 'smart_display' : dlModal.type === 'instagram' ? 'photo_camera' : 'audio_file'}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{dlModal.phase === 'input' ? 'Sound herunterladen'
|
||||||
|
: dlModal.phase === 'downloading' ? 'Wird heruntergeladen...'
|
||||||
|
: dlModal.phase === 'done' ? 'Fertig!'
|
||||||
|
: 'Fehler'}
|
||||||
|
</span>
|
||||||
|
{dlModal.phase !== 'downloading' && (
|
||||||
|
<button className="dl-modal-close" onClick={() => setDlModal(null)}>
|
||||||
|
<span className="material-icons" style={{ fontSize: 16 }}>close</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dl-modal-body">
|
||||||
|
{/* URL badge */}
|
||||||
|
<div className="dl-modal-url">
|
||||||
|
<span className={`dl-modal-tag ${dlModal.type ?? ''}`}>
|
||||||
|
{dlModal.type === 'youtube' ? 'YouTube' : dlModal.type === 'instagram' ? 'Instagram' : 'MP3'}
|
||||||
|
</span>
|
||||||
|
<span className="dl-modal-url-text" title={dlModal.url}>
|
||||||
|
{dlModal.url.length > 60 ? dlModal.url.slice(0, 57) + '...' : dlModal.url}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filename input (input phase only) */}
|
||||||
|
{dlModal.phase === 'input' && (
|
||||||
|
<div className="dl-modal-field">
|
||||||
|
<label className="dl-modal-label">Dateiname</label>
|
||||||
|
<div className="dl-modal-input-wrap">
|
||||||
|
<input
|
||||||
|
className="dl-modal-input"
|
||||||
|
type="text"
|
||||||
|
placeholder={dlModal.type === 'mp3' ? 'Dateiname...' : 'Wird automatisch erkannt...'}
|
||||||
|
value={dlModal.filename}
|
||||||
|
onChange={e => setDlModal(prev => prev ? { ...prev, filename: e.target.value } : null)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') void handleModalDownload(); }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<span className="dl-modal-ext">.mp3</span>
|
||||||
|
</div>
|
||||||
|
<span className="dl-modal-hint">Leer lassen = automatischer Name</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress (downloading phase) */}
|
||||||
|
{dlModal.phase === 'downloading' && (
|
||||||
|
<div className="dl-modal-progress">
|
||||||
|
<div className="dl-modal-spinner" />
|
||||||
|
<span>
|
||||||
|
{dlModal.type === 'youtube' || dlModal.type === 'instagram'
|
||||||
|
? 'Audio wird extrahiert...'
|
||||||
|
: 'MP3 wird heruntergeladen...'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success */}
|
||||||
|
{dlModal.phase === 'done' && (
|
||||||
|
<div className="dl-modal-success">
|
||||||
|
<span className="material-icons dl-modal-check">check_circle</span>
|
||||||
|
<span>Gespeichert als <b>{dlModal.savedName}</b></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{dlModal.phase === 'error' && (
|
||||||
|
<div className="dl-modal-error">
|
||||||
|
<span className="material-icons" style={{ color: '#e74c3c' }}>error</span>
|
||||||
|
<span>{dlModal.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{dlModal.phase === 'input' && (
|
||||||
|
<div className="dl-modal-actions">
|
||||||
|
<button className="dl-modal-cancel" onClick={() => setDlModal(null)}>Abbrechen</button>
|
||||||
|
<button className="dl-modal-submit" onClick={() => void handleModalDownload()}>
|
||||||
|
<span className="material-icons" style={{ fontSize: 16 }}>download</span>
|
||||||
|
Herunterladen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{dlModal.phase === 'error' && (
|
||||||
|
<div className="dl-modal-actions">
|
||||||
|
<button className="dl-modal-cancel" onClick={() => setDlModal(null)}>Schliessen</button>
|
||||||
|
<button className="dl-modal-submit" onClick={() => setDlModal(prev => prev ? { ...prev, phase: 'input', error: undefined } : null)}>
|
||||||
|
<span className="material-icons" style={{ fontSize: 16 }}>refresh</span>
|
||||||
|
Nochmal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -195,15 +195,24 @@ export async function adminRename(from: string, to: string): Promise<string> {
|
||||||
return data?.to as string;
|
return data?.to as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function playUrl(url: string, guildId: string, channelId: string, volume: number): Promise<void> {
|
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`, {
|
const res = await fetch(`${API_BASE}/play-url`, {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
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(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
throw new Error(data?.error || 'Play-URL fehlgeschlagen');
|
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. */
|
/** Lädt eine einzelne MP3/WAV-Datei hoch. Gibt den gespeicherten Dateinamen zurück. */
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,90 @@ input, select {
|
||||||
50% { box-shadow: 0 0 12px rgba(35, 165, 90, .8); }
|
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(--bg-primary);
|
||||||
|
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 Icon Button ── */
|
||||||
.admin-btn-icon {
|
.admin-btn-icon {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
|
|
@ -557,6 +641,24 @@ input, select {
|
||||||
pointer-events: none;
|
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 ── */
|
/* ── Toolbar Buttons ── */
|
||||||
.tb-btn {
|
.tb-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1122,7 +1224,7 @@ input, select {
|
||||||
border: 1px solid rgba(var(--accent-rgb, 88, 101, 242), .2);
|
border: 1px solid rgba(var(--accent-rgb, 88, 101, 242), .2);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
max-width: 200px;
|
max-width: none;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
animation: np-fade-in 300ms ease;
|
animation: np-fade-in 300ms ease;
|
||||||
}
|
}
|
||||||
|
|
@ -1135,8 +1237,6 @@ input, select {
|
||||||
.np-name {
|
.np-name {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1766,7 +1866,7 @@ input, select {
|
||||||
}
|
}
|
||||||
|
|
||||||
.now-playing {
|
.now-playing {
|
||||||
max-width: 120px;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar .tb-btn {
|
.toolbar .tb-btn {
|
||||||
|
|
@ -1854,7 +1954,6 @@ input, select {
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
box-shadow: 0 8px 40px rgba(0, 0, 0, .45);
|
box-shadow: 0 8px 40px rgba(0, 0, 0, .45);
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
overflow: hidden;
|
|
||||||
animation: slide-up 200ms cubic-bezier(.16,1,.3,1);
|
animation: slide-up 200ms cubic-bezier(.16,1,.3,1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1928,7 +2027,6 @@ input, select {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-normal);
|
color: var(--text-normal);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1943,7 +2041,6 @@ input, select {
|
||||||
height: 3px;
|
height: 3px;
|
||||||
background: rgba(255, 255, 255, .07);
|
background: rgba(255, 255, 255, .07);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
overflow: hidden;
|
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1984,6 +2081,141 @@ input, select {
|
||||||
margin-top: 2px;
|
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
|
Utility
|
||||||
──────────────────────────────────────────── */
|
──────────────────────────────────────────── */
|
||||||
|
|
@ -1993,7 +2225,6 @@ input, select {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: -1px;
|
margin: -1px;
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
clip: rect(0, 0, 0, 0);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border-width: 0;
|
border-width: 0;
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export type VoiceChannelInfo = {
|
||||||
guildName: string;
|
guildName: string;
|
||||||
channelId: string;
|
channelId: string;
|
||||||
channelName: string;
|
channelName: string;
|
||||||
|
members?: number;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue