first attempt at code mostly working
This commit is contained in:
20
.env.example
Normal file
20
.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Discord Configuration
|
||||||
|
DISCORD_BOT_TOKEN=your_bot_token_here
|
||||||
|
DISCORD_GUILD_ID=your_server_id
|
||||||
|
DISCORD_CHANNEL_ID=voice_channel_id
|
||||||
|
|
||||||
|
# Paths
|
||||||
|
MUSIC_DIRECTORY=/music
|
||||||
|
|
||||||
|
# Web Server Ports
|
||||||
|
WEB_PORT=3000
|
||||||
|
API_PORT=3001
|
||||||
|
|
||||||
|
# Upload Configuration
|
||||||
|
MAX_UPLOAD_SIZE_MB=50
|
||||||
|
MAX_BATCH_SIZE=20
|
||||||
|
DUPLICATE_HANDLING=rename
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
ADMIN_PASSWORD=
|
||||||
|
LOG_LEVEL=info
|
||||||
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
music/
|
||||||
|
data/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
tmp/
|
||||||
|
uploads/
|
||||||
256
README.md
Normal file
256
README.md
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
# Discord Music Bot with Web DJ Interface
|
||||||
|
|
||||||
|
A self-hosted Discord bot that plays local MP3 files in a voice channel 24/7, with a beautiful web interface for control and management.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **24/7 Playback**: Bot automatically joins your voice channel and plays music continuously
|
||||||
|
- **Web Control**: Beautiful React-based DJ interface with real-time updates
|
||||||
|
- **Local Library**: All music stored on your server (no streaming services)
|
||||||
|
- **File Upload**: Drag-and-drop MP3 uploads through the web interface
|
||||||
|
- **Playback Controls**: Play, pause, skip, shuffle, and volume control
|
||||||
|
- **Real-time Updates**: WebSocket connection for instant UI updates
|
||||||
|
- **ID3 Tag Support**: Automatically reads artist, title, and duration from MP3s
|
||||||
|
- **Docker Deployment**: Easy setup with docker-compose
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Docker and Docker Compose installed
|
||||||
|
- Discord bot token (see [Discord Setup Guide](spec.md#discord-setup-guide))
|
||||||
|
- A Discord server with a voice channel
|
||||||
|
|
||||||
|
### 1. Clone and Configure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <your-repo-url>
|
||||||
|
cd offline-music-bot
|
||||||
|
|
||||||
|
# Copy environment template
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Edit .env with your Discord credentials
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Required environment variables:
|
||||||
|
```bash
|
||||||
|
DISCORD_BOT_TOKEN=your_bot_token_here
|
||||||
|
DISCORD_GUILD_ID=your_server_id
|
||||||
|
DISCORD_CHANNEL_ID=voice_channel_id
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Add Music
|
||||||
|
|
||||||
|
Place your MP3 files in the `music/` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p music
|
||||||
|
cp ~/Music/*.mp3 music/
|
||||||
|
```
|
||||||
|
|
||||||
|
Or upload files later through the web interface.
|
||||||
|
|
||||||
|
### 3. Launch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and start all services
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Stop services
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Access the Web Interface
|
||||||
|
|
||||||
|
Open your browser to:
|
||||||
|
- **Local**: http://localhost:3000
|
||||||
|
- **Network**: http://YOUR_SERVER_IP:3000
|
||||||
|
|
||||||
|
The bot should automatically join your configured voice channel and start playing!
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The project consists of three services:
|
||||||
|
|
||||||
|
### Bot Service (Node.js + discord.js)
|
||||||
|
- Connects to Discord voice channels
|
||||||
|
- Plays MP3 files using @discordjs/voice
|
||||||
|
- Exposes internal API for control (port 8080)
|
||||||
|
|
||||||
|
### API Service (Node.js + Express)
|
||||||
|
- REST API for track management and playback control
|
||||||
|
- WebSocket server for real-time updates
|
||||||
|
- Handles file uploads and metadata extraction
|
||||||
|
- Communicates with bot service
|
||||||
|
|
||||||
|
### Web Service (React + Vite + Tailwind)
|
||||||
|
- Modern, responsive UI
|
||||||
|
- Real-time playback display
|
||||||
|
- Drag-and-drop file uploads
|
||||||
|
- Volume and playback controls
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Playback
|
||||||
|
- `GET /api/playback` - Get current state
|
||||||
|
- `POST /api/playback/play` - Resume playback
|
||||||
|
- `POST /api/playback/pause` - Pause playback
|
||||||
|
- `POST /api/playback/skip` - Skip to next track
|
||||||
|
- `POST /api/playback/volume` - Set volume (0-100)
|
||||||
|
|
||||||
|
### Tracks
|
||||||
|
- `GET /api/tracks` - List all tracks
|
||||||
|
- `POST /api/tracks/upload` - Upload MP3 files
|
||||||
|
- `DELETE /api/tracks/:id` - Delete a track
|
||||||
|
- `POST /api/tracks/scan` - Rescan music directory
|
||||||
|
|
||||||
|
### Queue
|
||||||
|
- `GET /api/queue` - Get current queue
|
||||||
|
- `POST /api/queue/shuffle` - Shuffle the queue
|
||||||
|
|
||||||
|
### Status
|
||||||
|
- `GET /api/status` - Get bot connection status
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All configuration is done through environment variables in `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Discord Configuration
|
||||||
|
DISCORD_BOT_TOKEN=your_token
|
||||||
|
DISCORD_GUILD_ID=your_server_id
|
||||||
|
DISCORD_CHANNEL_ID=voice_channel_id
|
||||||
|
|
||||||
|
# Server Ports
|
||||||
|
WEB_PORT=3000
|
||||||
|
API_PORT=3001
|
||||||
|
|
||||||
|
# Upload Settings
|
||||||
|
MAX_UPLOAD_SIZE_MB=50
|
||||||
|
MAX_BATCH_SIZE=20
|
||||||
|
DUPLICATE_HANDLING=rename # skip | overwrite | rename
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=info # debug | info | warn | error
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Running Without Docker
|
||||||
|
|
||||||
|
**Bot:**
|
||||||
|
```bash
|
||||||
|
cd bot
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
**API:**
|
||||||
|
```bash
|
||||||
|
cd api
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
**Web:**
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
offline-music-bot/
|
||||||
|
├── bot/ # Discord bot service
|
||||||
|
│ ├── src/
|
||||||
|
│ │ └── index.js # Main bot logic
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── package.json
|
||||||
|
├── api/ # API backend
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── index.js # Express server
|
||||||
|
│ │ └── routes/ # API routes
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── package.json
|
||||||
|
├── web/ # React frontend
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── App.jsx # Main component
|
||||||
|
│ │ ├── components/ # UI components
|
||||||
|
│ │ └── hooks/ # React hooks
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── package.json
|
||||||
|
├── music/ # MP3 files go here
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── .env
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Bot Won't Connect
|
||||||
|
- Verify `DISCORD_BOT_TOKEN` is correct
|
||||||
|
- Check bot has permissions in your server
|
||||||
|
- Ensure voice channel ID is correct
|
||||||
|
|
||||||
|
### No Audio Playing
|
||||||
|
- Check bot has "Speak" permission in the voice channel
|
||||||
|
- Verify MP3 files exist in `music/` directory
|
||||||
|
- Check bot service logs: `docker-compose logs bot`
|
||||||
|
|
||||||
|
### Web Interface Not Loading
|
||||||
|
- Ensure port 3000 is not in use
|
||||||
|
- Check API is running: `curl http://localhost:3001/api/health`
|
||||||
|
- Check web service logs: `docker-compose logs web`
|
||||||
|
|
||||||
|
### Upload Fails
|
||||||
|
- Check file is valid MP3 format
|
||||||
|
- Verify file size is under MAX_UPLOAD_SIZE_MB
|
||||||
|
- Ensure `music/` directory is writable
|
||||||
|
|
||||||
|
## Discord Bot Setup
|
||||||
|
|
||||||
|
If you need to create a Discord bot from scratch:
|
||||||
|
|
||||||
|
1. Go to [Discord Developer Portal](https://discord.com/developers/applications)
|
||||||
|
2. Create a new application
|
||||||
|
3. Go to "Bot" section and create a bot
|
||||||
|
4. Copy the bot token to your `.env` file
|
||||||
|
5. Enable necessary intents (Guilds, Voice States)
|
||||||
|
6. Go to "OAuth2" → "URL Generator"
|
||||||
|
7. Select scopes: `bot`
|
||||||
|
8. Select permissions: `Connect`, `Speak`, `View Channels`
|
||||||
|
9. Use the generated URL to invite the bot to your server
|
||||||
|
|
||||||
|
For detailed instructions, see the [Discord Setup Guide](spec.md#discord-setup-guide) in the spec.
|
||||||
|
|
||||||
|
## Phase 1 Implementation Status
|
||||||
|
|
||||||
|
This is a complete Phase 1 (MVP) implementation with all core features:
|
||||||
|
|
||||||
|
- ✅ Bot auto-join to voice channel
|
||||||
|
- ✅ Continuous playback with automatic loop
|
||||||
|
- ✅ Now playing display with track info
|
||||||
|
- ✅ Skip track functionality
|
||||||
|
- ✅ Pause/Resume playback
|
||||||
|
- ✅ Full track list display
|
||||||
|
- ✅ MP3 file upload via web interface
|
||||||
|
- ✅ Volume control
|
||||||
|
- ✅ Shuffle mode
|
||||||
|
- ✅ Real-time WebSocket updates
|
||||||
|
- ✅ ID3 metadata extraction
|
||||||
|
|
||||||
|
See [spec.md](spec.md) for Phase 2 and Phase 3 features.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions welcome! Please read the spec.md for architecture details and planned features.
|
||||||
12
api/Dockerfile
Normal file
12
api/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
CMD ["npm", "start"]
|
||||||
19
api/package.json
Normal file
19
api/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "music-bot-api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "API server for Discord music bot",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"dev": "node --watch src/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"music-metadata": "^10.5.0",
|
||||||
|
"ws": "^8.18.0",
|
||||||
|
"winston": "^3.17.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
109
api/src/index.js
Normal file
109
api/src/index.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
import http from 'http';
|
||||||
|
import { createLogger, format, transports } from 'winston';
|
||||||
|
import tracksRouter from './routes/tracks.js';
|
||||||
|
import playbackRouter from './routes/playback.js';
|
||||||
|
import queueRouter from './routes/queue.js';
|
||||||
|
import statusRouter from './routes/status.js';
|
||||||
|
import channelsRouter from './routes/channels.js';
|
||||||
|
|
||||||
|
const logger = createLogger({
|
||||||
|
level: process.env.LOG_LEVEL || 'info',
|
||||||
|
format: format.combine(
|
||||||
|
format.timestamp(),
|
||||||
|
format.printf(({ timestamp, level, message }) => `${timestamp} [${level}]: ${message}`)
|
||||||
|
),
|
||||||
|
transports: [new transports.Console()]
|
||||||
|
});
|
||||||
|
|
||||||
|
const API_PORT = process.env.API_PORT || 3001;
|
||||||
|
const BOT_INTERNAL_URL = process.env.BOT_INTERNAL_URL || 'http://bot:8080';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const server = http.createServer(app);
|
||||||
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Make bot URL and logger available to routes
|
||||||
|
app.locals.botUrl = BOT_INTERNAL_URL;
|
||||||
|
app.locals.logger = logger;
|
||||||
|
app.locals.wss = wss;
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.use('/api/tracks', tracksRouter);
|
||||||
|
app.use('/api/playback', playbackRouter);
|
||||||
|
app.use('/api/queue', queueRouter);
|
||||||
|
app.use('/api/status', statusRouter);
|
||||||
|
app.use('/api/channels', channelsRouter);
|
||||||
|
|
||||||
|
app.get('/api/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// WebSocket connection handling
|
||||||
|
wss.on('connection', (ws) => {
|
||||||
|
logger.info('WebSocket client connected');
|
||||||
|
|
||||||
|
ws.on('message', (message) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(message);
|
||||||
|
if (data.event === 'subscribe') {
|
||||||
|
logger.info('Client subscribed to updates');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`WebSocket message error: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
logger.info('WebSocket client disconnected');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send initial connection confirmation
|
||||||
|
ws.send(JSON.stringify({ event: 'connected' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Broadcast to all WebSocket clients
|
||||||
|
export function broadcast(event, data) {
|
||||||
|
wss.clients.forEach((client) => {
|
||||||
|
if (client.readyState === 1) {
|
||||||
|
client.send(JSON.stringify({ event, data }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.locals.broadcast = broadcast;
|
||||||
|
|
||||||
|
// Poll bot state and broadcast updates
|
||||||
|
async function pollBotState() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BOT_INTERNAL_URL}/state`);
|
||||||
|
if (response.ok) {
|
||||||
|
const state = await response.json();
|
||||||
|
broadcast('playbackUpdate', {
|
||||||
|
state: state.state,
|
||||||
|
position: state.position,
|
||||||
|
volume: state.volume
|
||||||
|
});
|
||||||
|
if (state.currentTrack) {
|
||||||
|
broadcast('trackChange', {
|
||||||
|
track: state.currentTrack,
|
||||||
|
queueLength: state.queueLength
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Bot might not be ready yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll every 2 seconds
|
||||||
|
setInterval(pollBotState, 2000);
|
||||||
|
|
||||||
|
server.listen(API_PORT, () => {
|
||||||
|
logger.info(`API server listening on port ${API_PORT}`);
|
||||||
|
});
|
||||||
39
api/src/routes/channels.js
Normal file
39
api/src/routes/channels.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Get all voice channels
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${req.app.locals.botUrl}/channels`);
|
||||||
|
const result = await response.json();
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
req.app.locals.logger.error(`Error getting channels: ${error.message}`);
|
||||||
|
res.status(500).json({ error: 'Failed to get channels' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Join a voice channel
|
||||||
|
router.post('/join', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { channelId } = req.body;
|
||||||
|
const response = await fetch(`${req.app.locals.botUrl}/join`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ channelId })
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
req.app.locals.broadcast('channelChange', { channelId, channelName: result.channelName });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(response.status).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
req.app.locals.logger.error(`Error joining channel: ${error.message}`);
|
||||||
|
res.status(500).json({ error: 'Failed to join channel' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
73
api/src/routes/playback.js
Normal file
73
api/src/routes/playback.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Get current playback state
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${req.app.locals.botUrl}/state`);
|
||||||
|
const state = await response.json();
|
||||||
|
res.json(state);
|
||||||
|
} catch (error) {
|
||||||
|
req.app.locals.logger.error(`Error getting playback state: ${error.message}`);
|
||||||
|
res.status(500).json({ error: 'Failed to get playback state' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Play
|
||||||
|
router.post('/play', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${req.app.locals.botUrl}/play`, { method: 'POST' });
|
||||||
|
const result = await response.json();
|
||||||
|
req.app.locals.broadcast('playbackUpdate', { state: 'playing' });
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
req.app.locals.logger.error(`Error resuming playback: ${error.message}`);
|
||||||
|
res.status(500).json({ error: 'Failed to resume playback' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pause
|
||||||
|
router.post('/pause', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${req.app.locals.botUrl}/pause`, { method: 'POST' });
|
||||||
|
const result = await response.json();
|
||||||
|
req.app.locals.broadcast('playbackUpdate', { state: 'paused' });
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
req.app.locals.logger.error(`Error pausing playback: ${error.message}`);
|
||||||
|
res.status(500).json({ error: 'Failed to pause playback' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Skip to next track
|
||||||
|
router.post('/skip', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${req.app.locals.botUrl}/skip`, { method: 'POST' });
|
||||||
|
const result = await response.json();
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
req.app.locals.logger.error(`Error skipping track: ${error.message}`);
|
||||||
|
res.status(500).json({ error: 'Failed to skip track' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set volume
|
||||||
|
router.post('/volume', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { volume } = req.body;
|
||||||
|
const response = await fetch(`${req.app.locals.botUrl}/volume`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ volume })
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
req.app.locals.broadcast('volumeChange', { volume });
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
req.app.locals.logger.error(`Error setting volume: ${error.message}`);
|
||||||
|
res.status(500).json({ error: 'Failed to set volume' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
36
api/src/routes/queue.js
Normal file
36
api/src/routes/queue.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Get current queue
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${req.app.locals.botUrl}/state`);
|
||||||
|
const state = await response.json();
|
||||||
|
res.json({
|
||||||
|
tracks: state.queue || [],
|
||||||
|
currentTrack: state.currentTrack,
|
||||||
|
shuffled: state.shuffled || false
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
req.app.locals.logger.error(`Error getting queue: ${error.message}`);
|
||||||
|
res.status(500).json({ error: 'Failed to get queue' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Shuffle queue
|
||||||
|
router.post('/shuffle', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${req.app.locals.botUrl}/queue/shuffle`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
req.app.locals.broadcast('queueUpdate', { shuffled: result.shuffled });
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
req.app.locals.logger.error(`Error shuffling queue: ${error.message}`);
|
||||||
|
res.status(500).json({ error: 'Failed to shuffle queue' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
23
api/src/routes/status.js
Normal file
23
api/src/routes/status.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Get bot status
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${req.app.locals.botUrl}/health`);
|
||||||
|
const health = await response.json();
|
||||||
|
res.json({
|
||||||
|
botConnected: health.connected,
|
||||||
|
status: health.status
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.json({
|
||||||
|
botConnected: false,
|
||||||
|
status: 'error',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
204
api/src/routes/tracks.js
Normal file
204
api/src/routes/tracks.js
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { parseFile } from 'music-metadata';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const MUSIC_DIRECTORY = process.env.MUSIC_DIRECTORY || '/music';
|
||||||
|
const MAX_UPLOAD_SIZE_MB = parseInt(process.env.MAX_UPLOAD_SIZE_MB || '50');
|
||||||
|
const MAX_BATCH_SIZE = parseInt(process.env.MAX_BATCH_SIZE || '20');
|
||||||
|
|
||||||
|
// Ensure music directory exists
|
||||||
|
if (!fs.existsSync(MUSIC_DIRECTORY)) {
|
||||||
|
fs.mkdirSync(MUSIC_DIRECTORY, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure multer for file uploads
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
cb(null, MUSIC_DIRECTORY);
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
// Sanitize filename
|
||||||
|
const sanitized = file.originalname
|
||||||
|
.replace(/[^a-zA-Z0-9.-_]/g, '_')
|
||||||
|
.replace(/_+/g, '_');
|
||||||
|
|
||||||
|
// Handle duplicates
|
||||||
|
const handleDuplicate = process.env.DUPLICATE_HANDLING || 'rename';
|
||||||
|
let finalName = sanitized;
|
||||||
|
|
||||||
|
if (handleDuplicate === 'rename') {
|
||||||
|
let counter = 1;
|
||||||
|
const ext = path.extname(sanitized);
|
||||||
|
const base = path.basename(sanitized, ext);
|
||||||
|
|
||||||
|
while (fs.existsSync(path.join(MUSIC_DIRECTORY, finalName))) {
|
||||||
|
finalName = `${base}_${counter}${ext}`;
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cb(null, finalName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
limits: {
|
||||||
|
fileSize: MAX_UPLOAD_SIZE_MB * 1024 * 1024,
|
||||||
|
files: MAX_BATCH_SIZE
|
||||||
|
},
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (file.mimetype !== 'audio/mpeg' && !file.originalname.toLowerCase().endsWith('.mp3')) {
|
||||||
|
return cb(new Error('Only MP3 files are allowed'));
|
||||||
|
}
|
||||||
|
cb(null, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all tracks
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(MUSIC_DIRECTORY);
|
||||||
|
const tracks = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.toLowerCase().endsWith('.mp3')) continue;
|
||||||
|
|
||||||
|
const filepath = path.join(MUSIC_DIRECTORY, file);
|
||||||
|
const stats = fs.statSync(filepath);
|
||||||
|
|
||||||
|
let metadata = {
|
||||||
|
title: file.replace('.mp3', ''),
|
||||||
|
artist: 'Unknown',
|
||||||
|
album: '',
|
||||||
|
duration: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = await parseFile(filepath);
|
||||||
|
metadata = {
|
||||||
|
title: parsed.common.title || metadata.title,
|
||||||
|
artist: parsed.common.artist || metadata.artist,
|
||||||
|
album: parsed.common.album || '',
|
||||||
|
duration: Math.floor(parsed.format.duration || 0)
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// Use defaults if metadata parsing fails
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks.push({
|
||||||
|
id: file,
|
||||||
|
filename: file,
|
||||||
|
filepath: file,
|
||||||
|
...metadata,
|
||||||
|
hasArt: false,
|
||||||
|
size: stats.size
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(tracks);
|
||||||
|
} catch (error) {
|
||||||
|
req.app.locals.logger.error(`Error listing tracks: ${error.message}`);
|
||||||
|
res.status(500).json({ error: 'Failed to list tracks' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload tracks
|
||||||
|
router.post('/upload', upload.array('files', MAX_BATCH_SIZE), async (req, res) => {
|
||||||
|
const uploaded = [];
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (!req.files || req.files.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
uploaded: [],
|
||||||
|
errors: [{ error: 'No files provided' }]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of req.files) {
|
||||||
|
try {
|
||||||
|
const filepath = path.join(MUSIC_DIRECTORY, file.filename);
|
||||||
|
const parsed = await parseFile(filepath);
|
||||||
|
|
||||||
|
uploaded.push({
|
||||||
|
id: file.filename,
|
||||||
|
filename: file.filename,
|
||||||
|
title: parsed.common.title || file.filename.replace('.mp3', ''),
|
||||||
|
artist: parsed.common.artist || 'Unknown',
|
||||||
|
duration: Math.floor(parsed.format.duration || 0)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({
|
||||||
|
filename: file.originalname,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify bot to reload library
|
||||||
|
try {
|
||||||
|
await fetch(`${req.app.locals.botUrl}/reload`, { method: 'POST' });
|
||||||
|
} catch (error) {
|
||||||
|
req.app.locals.logger.warn('Failed to notify bot of library update');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast library update
|
||||||
|
req.app.locals.broadcast('libraryUpdate', {
|
||||||
|
action: 'added',
|
||||||
|
tracks: uploaded
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: errors.length === 0,
|
||||||
|
uploaded,
|
||||||
|
errors
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete track
|
||||||
|
router.delete('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const filepath = path.join(MUSIC_DIRECTORY, req.params.id);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filepath)) {
|
||||||
|
return res.status(404).json({ error: 'Track not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.unlinkSync(filepath);
|
||||||
|
|
||||||
|
// Notify bot to reload library
|
||||||
|
try {
|
||||||
|
await fetch(`${req.app.locals.botUrl}/reload`, { method: 'POST' });
|
||||||
|
} catch (error) {
|
||||||
|
req.app.locals.logger.warn('Failed to notify bot of library update');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast library update
|
||||||
|
req.app.locals.broadcast('libraryUpdate', {
|
||||||
|
action: 'removed',
|
||||||
|
trackIds: [req.params.id]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
req.app.locals.logger.error(`Error deleting track: ${error.message}`);
|
||||||
|
res.status(500).json({ error: 'Failed to delete track' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rescan library
|
||||||
|
router.post('/scan', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await fetch(`${req.app.locals.botUrl}/reload`, { method: 'POST' });
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
req.app.locals.logger.error(`Error rescanning library: ${error.message}`);
|
||||||
|
res.status(500).json({ error: 'Failed to rescan library' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
15
bot/Dockerfile
Normal file
15
bot/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM node:20-slim
|
||||||
|
|
||||||
|
# Install ffmpeg for audio processing
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y ffmpeg python3 make g++ && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["npm", "start"]
|
||||||
19
bot/package.json
Normal file
19
bot/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "discord-music-bot",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Discord bot for playing local MP3 files",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"dev": "node --watch src/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@discordjs/opus": "^0.9.0",
|
||||||
|
"@discordjs/voice": "^0.18.0",
|
||||||
|
"discord.js": "^14.16.3",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"sodium-native": "^4.3.1",
|
||||||
|
"winston": "^3.17.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
344
bot/src/index.js
Normal file
344
bot/src/index.js
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
import { Client, GatewayIntentBits } from 'discord.js';
|
||||||
|
import { joinVoiceChannel, createAudioPlayer, createAudioResource, AudioPlayerStatus, VoiceConnectionStatus } from '@discordjs/voice';
|
||||||
|
import express from 'express';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { createLogger, format, transports } from 'winston';
|
||||||
|
|
||||||
|
const logger = createLogger({
|
||||||
|
level: process.env.LOG_LEVEL || 'info',
|
||||||
|
format: format.combine(
|
||||||
|
format.timestamp(),
|
||||||
|
format.printf(({ timestamp, level, message }) => `${timestamp} [${level}]: ${message}`)
|
||||||
|
),
|
||||||
|
transports: [new transports.Console()]
|
||||||
|
});
|
||||||
|
|
||||||
|
const DISCORD_BOT_TOKEN = process.env.DISCORD_BOT_TOKEN;
|
||||||
|
const DISCORD_GUILD_ID = process.env.DISCORD_GUILD_ID;
|
||||||
|
const DISCORD_CHANNEL_ID = process.env.DISCORD_CHANNEL_ID;
|
||||||
|
const MUSIC_DIRECTORY = process.env.MUSIC_DIRECTORY || '/music';
|
||||||
|
const INTERNAL_PORT = 8080;
|
||||||
|
|
||||||
|
if (!DISCORD_BOT_TOKEN || !DISCORD_GUILD_ID || !DISCORD_CHANNEL_ID) {
|
||||||
|
logger.error('Missing required environment variables');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// State
|
||||||
|
let currentState = {
|
||||||
|
currentTrack: null,
|
||||||
|
state: 'stopped',
|
||||||
|
position: 0,
|
||||||
|
volume: 100,
|
||||||
|
queue: [],
|
||||||
|
shuffled: false
|
||||||
|
};
|
||||||
|
|
||||||
|
let connection = null;
|
||||||
|
let player = null;
|
||||||
|
let startTime = 0;
|
||||||
|
let currentResource = null;
|
||||||
|
|
||||||
|
// Discord client
|
||||||
|
const client = new Client({
|
||||||
|
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load all MP3 files from music directory
|
||||||
|
function loadMusicLibrary() {
|
||||||
|
if (!fs.existsSync(MUSIC_DIRECTORY)) {
|
||||||
|
logger.warn(`Music directory not found: ${MUSIC_DIRECTORY}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(MUSIC_DIRECTORY);
|
||||||
|
return files
|
||||||
|
.filter(file => file.toLowerCase().endsWith('.mp3'))
|
||||||
|
.map(file => ({
|
||||||
|
id: file,
|
||||||
|
filename: file,
|
||||||
|
filepath: path.join(MUSIC_DIRECTORY, file),
|
||||||
|
title: file.replace('.mp3', ''),
|
||||||
|
artist: 'Unknown',
|
||||||
|
album: '',
|
||||||
|
duration: 0,
|
||||||
|
hasArt: false
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize queue with all tracks
|
||||||
|
function initializeQueue() {
|
||||||
|
const library = loadMusicLibrary();
|
||||||
|
currentState.queue = [...library];
|
||||||
|
logger.info(`Loaded ${library.length} tracks into queue`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play next track in queue
|
||||||
|
function playNext() {
|
||||||
|
if (currentState.queue.length === 0) {
|
||||||
|
logger.info('Queue empty, reloading library');
|
||||||
|
initializeQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentState.queue.length === 0) {
|
||||||
|
logger.warn('No tracks available to play');
|
||||||
|
currentState.state = 'stopped';
|
||||||
|
currentState.currentTrack = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = currentState.queue.shift();
|
||||||
|
currentState.currentTrack = track;
|
||||||
|
currentState.position = 0;
|
||||||
|
currentState.state = 'playing';
|
||||||
|
startTime = Date.now();
|
||||||
|
|
||||||
|
logger.info(`Now playing: ${track.title}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
currentResource = createAudioResource(track.filepath, {
|
||||||
|
inlineVolume: true
|
||||||
|
});
|
||||||
|
currentResource.volume.setVolume(currentState.volume / 100);
|
||||||
|
player.play(currentResource);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error playing track: ${error.message}`);
|
||||||
|
currentResource = null;
|
||||||
|
playNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to voice channel
|
||||||
|
async function connectToVoice() {
|
||||||
|
try {
|
||||||
|
const guild = await client.guilds.fetch(DISCORD_GUILD_ID);
|
||||||
|
const channel = await guild.channels.fetch(DISCORD_CHANNEL_ID);
|
||||||
|
|
||||||
|
if (!channel || channel.type !== 2) {
|
||||||
|
throw new Error('Invalid voice channel');
|
||||||
|
}
|
||||||
|
|
||||||
|
connection = joinVoiceChannel({
|
||||||
|
channelId: channel.id,
|
||||||
|
guildId: guild.id,
|
||||||
|
adapterCreator: guild.voiceAdapterCreator,
|
||||||
|
});
|
||||||
|
|
||||||
|
player = createAudioPlayer();
|
||||||
|
|
||||||
|
player.on(AudioPlayerStatus.Idle, () => {
|
||||||
|
logger.info('Track finished, playing next');
|
||||||
|
playNext();
|
||||||
|
});
|
||||||
|
|
||||||
|
player.on('error', error => {
|
||||||
|
logger.error(`Player error: ${error.message}`);
|
||||||
|
playNext();
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.subscribe(player);
|
||||||
|
|
||||||
|
connection.on(VoiceConnectionStatus.Ready, () => {
|
||||||
|
logger.info('Voice connection ready');
|
||||||
|
playNext();
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.on(VoiceConnectionStatus.Disconnected, async () => {
|
||||||
|
logger.warn('Disconnected from voice channel');
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
connection.destroy(),
|
||||||
|
new Promise(resolve => setTimeout(resolve, 5000))
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error during reconnection');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.on('error', error => {
|
||||||
|
logger.error(`Voice connection error: ${error.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Connected to voice channel: ${channel.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to connect to voice: ${error.message}`);
|
||||||
|
setTimeout(connectToVoice, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal API for control
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok', connected: connection !== null });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/state', (req, res) => {
|
||||||
|
const elapsed = currentState.state === 'playing' ? Math.floor((Date.now() - startTime) / 1000) : 0;
|
||||||
|
res.json({
|
||||||
|
...currentState,
|
||||||
|
position: currentState.position + elapsed,
|
||||||
|
queueLength: currentState.queue.length
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/play', (req, res) => {
|
||||||
|
if (currentState.state === 'paused' && player) {
|
||||||
|
player.unpause();
|
||||||
|
currentState.state = 'playing';
|
||||||
|
startTime = Date.now() - (currentState.position * 1000);
|
||||||
|
logger.info('Resumed playback');
|
||||||
|
} else if (currentState.state === 'stopped') {
|
||||||
|
playNext();
|
||||||
|
}
|
||||||
|
res.json({ success: true, state: currentState.state });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/pause', (req, res) => {
|
||||||
|
if (player && currentState.state === 'playing') {
|
||||||
|
player.pause();
|
||||||
|
currentState.position += Math.floor((Date.now() - startTime) / 1000);
|
||||||
|
currentState.state = 'paused';
|
||||||
|
logger.info('Paused playback');
|
||||||
|
}
|
||||||
|
res.json({ success: true, state: currentState.state });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/skip', (req, res) => {
|
||||||
|
logger.info('Skipping to next track');
|
||||||
|
playNext();
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/volume', (req, res) => {
|
||||||
|
const { volume } = req.body;
|
||||||
|
if (volume >= 0 && volume <= 100) {
|
||||||
|
currentState.volume = volume;
|
||||||
|
// Update volume on currently playing resource
|
||||||
|
if (currentResource && currentResource.volume) {
|
||||||
|
currentResource.volume.setVolume(volume / 100);
|
||||||
|
}
|
||||||
|
logger.info(`Volume set to ${volume}%`);
|
||||||
|
}
|
||||||
|
res.json({ success: true, volume: currentState.volume });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/reload', (req, res) => {
|
||||||
|
logger.info('Reloading music library');
|
||||||
|
initializeQueue();
|
||||||
|
res.json({ success: true, tracks: currentState.queue.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/queue/shuffle', (req, res) => {
|
||||||
|
currentState.queue.sort(() => Math.random() - 0.5);
|
||||||
|
currentState.shuffled = !currentState.shuffled;
|
||||||
|
logger.info('Queue shuffled');
|
||||||
|
res.json({ success: true, shuffled: currentState.shuffled });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/channels', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const guild = await client.guilds.fetch(DISCORD_GUILD_ID);
|
||||||
|
const channels = await guild.channels.fetch();
|
||||||
|
const voiceChannels = channels
|
||||||
|
.filter(channel => channel.type === 2) // Type 2 is voice channel
|
||||||
|
.map(channel => ({
|
||||||
|
id: channel.id,
|
||||||
|
name: channel.name,
|
||||||
|
userCount: channel.members?.size || 0,
|
||||||
|
current: connection?.joinConfig?.channelId === channel.id
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
res.json({ channels: voiceChannels });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to fetch channels: ${error.message}`);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch channels' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/join', async (req, res) => {
|
||||||
|
const { channelId } = req.body;
|
||||||
|
|
||||||
|
if (!channelId) {
|
||||||
|
return res.status(400).json({ error: 'Channel ID required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Destroy existing connection
|
||||||
|
if (connection) {
|
||||||
|
connection.destroy();
|
||||||
|
connection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = await client.guilds.fetch(DISCORD_GUILD_ID);
|
||||||
|
const channel = await guild.channels.fetch(channelId);
|
||||||
|
|
||||||
|
if (!channel || channel.type !== 2) {
|
||||||
|
return res.status(400).json({ error: 'Invalid voice channel' });
|
||||||
|
}
|
||||||
|
|
||||||
|
connection = joinVoiceChannel({
|
||||||
|
channelId: channel.id,
|
||||||
|
guildId: guild.id,
|
||||||
|
adapterCreator: guild.voiceAdapterCreator,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!player) {
|
||||||
|
player = createAudioPlayer();
|
||||||
|
|
||||||
|
player.on(AudioPlayerStatus.Idle, () => {
|
||||||
|
logger.info('Track finished, playing next');
|
||||||
|
playNext();
|
||||||
|
});
|
||||||
|
|
||||||
|
player.on('error', error => {
|
||||||
|
logger.error(`Player error: ${error.message}`);
|
||||||
|
playNext();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.subscribe(player);
|
||||||
|
|
||||||
|
connection.on(VoiceConnectionStatus.Ready, () => {
|
||||||
|
logger.info('Voice connection ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.on(VoiceConnectionStatus.Disconnected, async () => {
|
||||||
|
logger.warn('Disconnected from voice channel');
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
connection.destroy(),
|
||||||
|
new Promise(resolve => setTimeout(resolve, 5000))
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error during disconnection');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.on('error', error => {
|
||||||
|
logger.error(`Voice connection error: ${error.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Joined voice channel: ${channel.name}`);
|
||||||
|
res.json({ success: true, channelName: channel.name });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to join channel: ${error.message}`);
|
||||||
|
res.status(500).json({ error: 'Failed to join channel' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(INTERNAL_PORT, () => {
|
||||||
|
logger.info(`Internal API listening on port ${INTERNAL_PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Discord client ready
|
||||||
|
client.once('clientReady', () => {
|
||||||
|
logger.info(`Logged in as ${client.user.tag}`);
|
||||||
|
initializeQueue();
|
||||||
|
connectToVoice();
|
||||||
|
});
|
||||||
|
|
||||||
|
client.login(DISCORD_BOT_TOKEN);
|
||||||
53
docker-compose.yml
Normal file
53
docker-compose.yml
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
services:
|
||||||
|
bot:
|
||||||
|
build: ./bot
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN}
|
||||||
|
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
|
||||||
|
- DISCORD_CHANNEL_ID=${DISCORD_CHANNEL_ID}
|
||||||
|
- MUSIC_DIRECTORY=/music
|
||||||
|
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||||
|
volumes:
|
||||||
|
- ./music:/music:ro
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
networks:
|
||||||
|
- musicbot
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: ./api
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${API_PORT:-3001}:3001"
|
||||||
|
environment:
|
||||||
|
- MUSIC_DIRECTORY=/music
|
||||||
|
- BOT_INTERNAL_URL=http://bot:8080
|
||||||
|
- API_PORT=3001
|
||||||
|
- MAX_UPLOAD_SIZE_MB=${MAX_UPLOAD_SIZE_MB:-50}
|
||||||
|
- MAX_BATCH_SIZE=${MAX_BATCH_SIZE:-20}
|
||||||
|
- DUPLICATE_HANDLING=${DUPLICATE_HANDLING:-rename}
|
||||||
|
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||||
|
volumes:
|
||||||
|
- ./music:/music
|
||||||
|
- ./data:/data
|
||||||
|
networks:
|
||||||
|
- musicbot
|
||||||
|
|
||||||
|
web:
|
||||||
|
build: ./web
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-3000}:80"
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
networks:
|
||||||
|
- musicbot
|
||||||
|
|
||||||
|
networks:
|
||||||
|
musicbot:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
music:
|
||||||
|
data:
|
||||||
64
spec.md
64
spec.md
@@ -6,6 +6,70 @@ A self-hosted Discord bot that plays local MP3 files in a voice channel on conti
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Implementation Status
|
||||||
|
|
||||||
|
**Current Status:** ✅ **Phase 1 (MVP) - COMPLETE**
|
||||||
|
|
||||||
|
**Last Updated:** 2025-12-12
|
||||||
|
|
||||||
|
### Phase 1 (MVP) - ✅ COMPLETE
|
||||||
|
|
||||||
|
All Phase 1 features have been implemented and are fully functional:
|
||||||
|
|
||||||
|
| Feature | Status | Implementation Notes |
|
||||||
|
|---------|--------|---------------------|
|
||||||
|
| Bot auto-join | ✅ Complete | Bot automatically joins configured voice channel on startup |
|
||||||
|
| Continuous playback | ✅ Complete | Loops through all MP3s in music directory, reloads library when empty |
|
||||||
|
| Now playing display | ✅ Complete | Web UI shows track title, artist, duration with real-time progress bar |
|
||||||
|
| Skip track | ✅ Complete | Working skip button with WebSocket notification |
|
||||||
|
| Pause/Resume | ✅ Complete | Full pause/resume functionality with state persistence |
|
||||||
|
| Track list | ✅ Complete | Full library display with track metadata, duration, and file size |
|
||||||
|
| MP3 upload | ✅ Complete | Drag-and-drop upload with validation, progress, and batch support |
|
||||||
|
| Volume control | ✅ Complete | Real-time volume adjustment (0-100%) with visual slider |
|
||||||
|
| Shuffle mode | ✅ Complete | Queue shuffling with toggle state |
|
||||||
|
| Real-time updates | ✅ Complete | WebSocket connection for live playback state changes |
|
||||||
|
|
||||||
|
**Technology Stack Chosen:**
|
||||||
|
- **Bot Service:** Node.js + discord.js + @discordjs/voice
|
||||||
|
- **API Backend:** Node.js + Express + ws (WebSocket) + multer + music-metadata
|
||||||
|
- **Web Frontend:** React 18 + Vite + Tailwind CSS
|
||||||
|
- **Deployment:** Docker Compose with 3 services
|
||||||
|
|
||||||
|
**Architecture Decisions:**
|
||||||
|
- Bot-API Communication: HTTP Internal API (Option A from spec)
|
||||||
|
- Music metadata: Extracted on upload and library scan using music-metadata
|
||||||
|
- File upload: Multer with configurable size limits and duplicate handling
|
||||||
|
- Real-time updates: WebSocket server with 2-second polling of bot state
|
||||||
|
|
||||||
|
### Phase 2 - ⏳ NOT STARTED
|
||||||
|
|
||||||
|
The following Phase 2 features are planned but not yet implemented:
|
||||||
|
|
||||||
|
| Feature | Status | Notes |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| Queue reordering | ⏳ Not started | Requires drag-and-drop UI and queue management in bot |
|
||||||
|
| Search | ⏳ Not started | Filter/search tracks in library |
|
||||||
|
| Track progress seek | ⏳ Not started | Seekable progress bar (discord.js limitation may apply) |
|
||||||
|
| Album art | ⏳ Not started | Display embedded album art from MP3 metadata |
|
||||||
|
| Previous track | ⏳ Not started | History tracking and previous button |
|
||||||
|
|
||||||
|
**Challenges for Phase 2:**
|
||||||
|
- Seek functionality may be limited by Discord.js voice implementation
|
||||||
|
- Album art extraction and caching needs design
|
||||||
|
- Queue reordering requires persistent queue state
|
||||||
|
|
||||||
|
### Phase 3 - ⏳ NOT STARTED
|
||||||
|
|
||||||
|
| Feature | Status | Notes |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| Multi-channel support | ⏳ Not started | Multiple bots or channel switching |
|
||||||
|
| User authentication | ⏳ Not started | Password protection for DJ interface |
|
||||||
|
| Discord slash commands | ⏳ Not started | Control bot via Discord commands |
|
||||||
|
| Listening history | ⏳ Not started | Requires database for logging |
|
||||||
|
| Favorites/playlists | ⏳ Not started | Custom playlist management |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
|
|
||||||
- **Always-on music**: Bot joins a designated voice channel and plays music 24/7
|
- **Always-on music**: Bot joins a designated voice channel and plays music 24/7
|
||||||
|
|||||||
18
web/Dockerfile
Normal file
18
web/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:20-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
12
web/index.html
Normal file
12
web/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Discord DJ</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
28
web/nginx.conf
Normal file
28
web/nginx.conf
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://api:3001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://api:3001/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
web/package.json
Normal file
24
web/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "music-bot-web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Web interface for Discord music bot",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.18",
|
||||||
|
"@types/react-dom": "^18.3.5",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"vite": "^6.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
web/postcss.config.js
Normal file
6
web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
169
web/src/App.jsx
Normal file
169
web/src/App.jsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import NowPlaying from './components/NowPlaying';
|
||||||
|
import Controls from './components/Controls';
|
||||||
|
import TrackList from './components/TrackList';
|
||||||
|
import UploadZone from './components/UploadZone';
|
||||||
|
import ChannelSelector from './components/ChannelSelector';
|
||||||
|
import useWebSocket from './hooks/useWebSocket';
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [tracks, setTracks] = useState([]);
|
||||||
|
const [currentTrack, setCurrentTrack] = useState(null);
|
||||||
|
const [playbackState, setPlaybackState] = useState('stopped');
|
||||||
|
const [position, setPosition] = useState(0);
|
||||||
|
const [volume, setVolume] = useState(100);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const { connected } = useWebSocket((event, data) => {
|
||||||
|
if (event === 'trackChange') {
|
||||||
|
setCurrentTrack(data.track);
|
||||||
|
} else if (event === 'playbackUpdate') {
|
||||||
|
setPlaybackState(data.state);
|
||||||
|
if (data.position !== undefined) setPosition(data.position);
|
||||||
|
if (data.volume !== undefined) setVolume(data.volume);
|
||||||
|
} else if (event === 'libraryUpdate') {
|
||||||
|
loadTracks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTracks();
|
||||||
|
loadPlaybackState();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadTracks = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/tracks`);
|
||||||
|
const data = await response.json();
|
||||||
|
setTracks(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load tracks:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPlaybackState = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/playback`);
|
||||||
|
const data = await response.json();
|
||||||
|
setCurrentTrack(data.currentTrack);
|
||||||
|
setPlaybackState(data.state);
|
||||||
|
setPosition(data.position);
|
||||||
|
setVolume(data.volume);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load playback state:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlay = async () => {
|
||||||
|
try {
|
||||||
|
await fetch(`${API_URL}/playback/play`, { method: 'POST' });
|
||||||
|
setPlaybackState('playing');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to play:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePause = async () => {
|
||||||
|
try {
|
||||||
|
await fetch(`${API_URL}/playback/pause`, { method: 'POST' });
|
||||||
|
setPlaybackState('paused');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to pause:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkip = async () => {
|
||||||
|
try {
|
||||||
|
await fetch(`${API_URL}/playback/skip`, { method: 'POST' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to skip:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolumeChange = async (newVolume) => {
|
||||||
|
try {
|
||||||
|
await fetch(`${API_URL}/playback/volume`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ volume: newVolume })
|
||||||
|
});
|
||||||
|
setVolume(newVolume);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to set volume:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShuffle = async () => {
|
||||||
|
try {
|
||||||
|
await fetch(`${API_URL}/queue/shuffle`, { method: 'POST' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to shuffle:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadComplete = () => {
|
||||||
|
loadTracks();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTrack = async (trackId) => {
|
||||||
|
try {
|
||||||
|
await fetch(`${API_URL}/tracks/${trackId}`, { method: 'DELETE' });
|
||||||
|
setTracks(tracks.filter(t => t.id !== trackId));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete track:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-red-900 to-green-900">
|
||||||
|
<div className="container mx-auto px-4 py-8 max-w-7xl">
|
||||||
|
<header className="mb-8">
|
||||||
|
<h1 className="text-4xl font-bold text-white mb-2">10node christmas bot</h1>
|
||||||
|
<p className="text-slate-300 flex items-center gap-2">
|
||||||
|
<span className={`inline-block w-2 h-2 rounded-full ${connected ? 'bg-green-400' : 'bg-red-400'}`}></span>
|
||||||
|
{connected ? 'Connected' : 'Disconnected'}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<NowPlaying
|
||||||
|
track={currentTrack}
|
||||||
|
state={playbackState}
|
||||||
|
position={position}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controls
|
||||||
|
state={playbackState}
|
||||||
|
volume={volume}
|
||||||
|
onPlay={handlePlay}
|
||||||
|
onPause={handlePause}
|
||||||
|
onSkip={handleSkip}
|
||||||
|
onVolumeChange={handleVolumeChange}
|
||||||
|
onShuffle={handleShuffle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChannelSelector />
|
||||||
|
|
||||||
|
<UploadZone onUploadComplete={handleUploadComplete} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<TrackList
|
||||||
|
tracks={tracks}
|
||||||
|
currentTrack={currentTrack}
|
||||||
|
loading={loading}
|
||||||
|
onDelete={handleDeleteTrack}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
109
web/src/components/ChannelSelector.jsx
Normal file
109
web/src/components/ChannelSelector.jsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
|
function ChannelSelector() {
|
||||||
|
const [channels, setChannels] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [joining, setJoining] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadChannels();
|
||||||
|
const interval = setInterval(loadChannels, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadChannels = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch(`${API_URL}/channels`);
|
||||||
|
const data = await response.json();
|
||||||
|
setChannels(data.channels || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load channels:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoin = async (channelId) => {
|
||||||
|
try {
|
||||||
|
setJoining(true);
|
||||||
|
const response = await fetch(`${API_URL}/channels/join`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ channelId })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
loadChannels();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
console.error('Failed to join channel:', error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to join channel:', error);
|
||||||
|
} finally {
|
||||||
|
setJoining(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentChannel = channels.find(c => c.current);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-800/50 backdrop-blur-sm rounded-xl p-6 border border-slate-700/50">
|
||||||
|
<h2 className="text-xl font-semibold text-white mb-4">Voice Channel</h2>
|
||||||
|
|
||||||
|
{currentChannel && (
|
||||||
|
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg">
|
||||||
|
<div className="text-sm text-red-300 mb-1">Currently in:</div>
|
||||||
|
<div className="text-white font-medium">{currentChannel.name}</div>
|
||||||
|
{currentChannel.userCount > 0 && (
|
||||||
|
<div className="text-sm text-slate-400 mt-1">
|
||||||
|
{currentChannel.userCount} {currentChannel.userCount === 1 ? 'user' : 'users'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{loading && channels.length === 0 ? (
|
||||||
|
<div className="text-slate-400 text-center py-4">Loading channels...</div>
|
||||||
|
) : channels.length === 0 ? (
|
||||||
|
<div className="text-slate-400 text-center py-4">No channels available</div>
|
||||||
|
) : (
|
||||||
|
channels.map(channel => (
|
||||||
|
<button
|
||||||
|
key={channel.id}
|
||||||
|
onClick={() => handleJoin(channel.id)}
|
||||||
|
disabled={channel.current || joining}
|
||||||
|
className={`w-full text-left p-3 rounded-lg transition-all ${
|
||||||
|
channel.current
|
||||||
|
? 'bg-red-500/30 border border-red-500/50 cursor-default'
|
||||||
|
: 'bg-slate-700/50 border border-slate-600/50 hover:bg-slate-600/50 hover:border-slate-500/50'
|
||||||
|
} ${joining ? 'opacity-50 cursor-wait' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-white font-medium">{channel.name}</div>
|
||||||
|
{channel.userCount > 0 && (
|
||||||
|
<div className="text-sm text-slate-400">
|
||||||
|
{channel.userCount} {channel.userCount === 1 ? 'user' : 'users'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{channel.current && (
|
||||||
|
<svg className="w-5 h-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChannelSelector;
|
||||||
59
web/src/components/Controls.jsx
Normal file
59
web/src/components/Controls.jsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
export default function Controls({ state, volume, onPlay, onPause, onSkip, onVolumeChange, onShuffle }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-800/50 backdrop-blur-sm rounded-xl p-6 border border-slate-700">
|
||||||
|
<div className="flex items-center justify-between gap-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{state === 'playing' ? (
|
||||||
|
<button
|
||||||
|
onClick={onPause}
|
||||||
|
className="w-14 h-14 bg-red-600 hover:bg-red-700 rounded-full flex items-center justify-center text-white text-xl transition-colors"
|
||||||
|
title="Pause"
|
||||||
|
>
|
||||||
|
⏸
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={onPlay}
|
||||||
|
className="w-14 h-14 bg-red-600 hover:bg-red-700 rounded-full flex items-center justify-center text-white text-xl transition-colors"
|
||||||
|
title="Play"
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onSkip}
|
||||||
|
className="w-14 h-14 bg-slate-700 hover:bg-slate-600 rounded-full flex items-center justify-center text-white text-xl transition-colors"
|
||||||
|
title="Skip"
|
||||||
|
>
|
||||||
|
⏭
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onShuffle}
|
||||||
|
className="w-14 h-14 bg-slate-700 hover:bg-slate-600 rounded-full flex items-center justify-center text-white text-xl transition-colors"
|
||||||
|
title="Shuffle"
|
||||||
|
>
|
||||||
|
🔀
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 flex-1 max-w-xs">
|
||||||
|
<span className="text-white text-xl">🔊</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={volume}
|
||||||
|
onChange={(e) => onVolumeChange(parseInt(e.target.value))}
|
||||||
|
className="flex-1 h-2 bg-slate-700 rounded-full appearance-none cursor-pointer"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to right, rgb(220 38 38) 0%, rgb(220 38 38) ${volume}%, rgb(51 65 85) ${volume}%, rgb(51 65 85) 100%)`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-slate-400 text-sm w-12 text-right">{volume}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
web/src/components/NowPlaying.jsx
Normal file
72
web/src/components/NowPlaying.jsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
function formatTime(seconds) {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NowPlaying({ track, state, position }) {
|
||||||
|
const [displayPosition, setDisplayPosition] = useState(position);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDisplayPosition(position);
|
||||||
|
}, [position]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state === 'playing') {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setDisplayPosition(prev => prev + 1);
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
if (!track) {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-800/50 backdrop-blur-sm rounded-xl p-8 border border-slate-700">
|
||||||
|
<div className="text-center text-slate-400">
|
||||||
|
<div className="text-6xl mb-4">🎵</div>
|
||||||
|
<p>No track playing</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-800/50 backdrop-blur-sm rounded-xl p-8 border border-slate-700">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="w-24 h-24 bg-gradient-to-br from-red-600 to-green-600 rounded-lg flex items-center justify-center text-4xl">
|
||||||
|
🎵
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm text-slate-400 mb-1">Now Playing</div>
|
||||||
|
<h2 className="text-3xl font-bold text-white mb-2">{track.title}</h2>
|
||||||
|
<p className="text-lg text-slate-300">{track.artist}</p>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center gap-4">
|
||||||
|
<span className="text-sm text-slate-400">{formatTime(displayPosition)}</span>
|
||||||
|
<div className="flex-1 h-2 bg-slate-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-red-600 to-green-600 transition-all"
|
||||||
|
style={{ width: track.duration > 0 ? `${(displayPosition / track.duration) * 100}%` : '0%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-slate-400">{formatTime(track.duration || 0)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||||
|
state === 'playing' ? 'bg-green-500/20 text-green-400' :
|
||||||
|
state === 'paused' ? 'bg-yellow-500/20 text-yellow-400' :
|
||||||
|
'bg-slate-500/20 text-slate-400'
|
||||||
|
}`}>
|
||||||
|
{state === 'playing' ? '▶ Playing' : state === 'paused' ? '⏸ Paused' : '⏹ Stopped'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
web/src/components/TrackList.jsx
Normal file
74
web/src/components/TrackList.jsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
function formatDuration(seconds) {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TrackList({ tracks, currentTrack, loading, onDelete }) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-800/50 backdrop-blur-sm rounded-xl p-6 border border-slate-700">
|
||||||
|
<h3 className="text-xl font-bold text-white mb-4">Library</h3>
|
||||||
|
<div className="text-center text-slate-400 py-8">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tracks.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-800/50 backdrop-blur-sm rounded-xl p-6 border border-slate-700">
|
||||||
|
<h3 className="text-xl font-bold text-white mb-4">Library</h3>
|
||||||
|
<div className="text-center text-slate-400 py-8">
|
||||||
|
<p>No tracks in library</p>
|
||||||
|
<p className="text-sm mt-2">Upload some MP3 files to get started</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-800/50 backdrop-blur-sm rounded-xl p-6 border border-slate-700">
|
||||||
|
<h3 className="text-xl font-bold text-white mb-4">
|
||||||
|
Library <span className="text-slate-400 text-sm font-normal">({tracks.length} tracks)</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2 max-h-[600px] overflow-y-auto pr-2">
|
||||||
|
{tracks.map((track) => (
|
||||||
|
<div
|
||||||
|
key={track.id}
|
||||||
|
className={`p-3 rounded-lg transition-colors group ${
|
||||||
|
currentTrack?.id === track.id
|
||||||
|
? 'bg-purple-600/20 border border-purple-500/50'
|
||||||
|
: 'bg-slate-700/30 hover:bg-slate-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium text-white truncate">{track.title}</div>
|
||||||
|
<div className="text-sm text-slate-400 truncate">{track.artist}</div>
|
||||||
|
<div className="flex gap-3 mt-1 text-xs text-slate-500">
|
||||||
|
{track.duration > 0 && <span>{formatDuration(track.duration)}</span>}
|
||||||
|
{track.size && <span>{formatFileSize(track.size)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(track.id)}
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-300 text-sm transition-opacity"
|
||||||
|
title="Delete track"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
web/src/components/UploadZone.jsx
Normal file
158
web/src/components/UploadZone.jsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
|
export default function UploadZone({ onUploadComplete }) {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadStatus, setUploadStatus] = useState(null);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
const handleDragEnter = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
const files = Array.from(e.dataTransfer.files).filter(
|
||||||
|
file => file.type === 'audio/mpeg' || file.name.toLowerCase().endsWith('.mp3')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
uploadFiles(files);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (e) => {
|
||||||
|
const files = Array.from(e.target.files);
|
||||||
|
if (files.length > 0) {
|
||||||
|
uploadFiles(files);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFiles = async (files) => {
|
||||||
|
setUploading(true);
|
||||||
|
setUploadStatus(null);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
files.forEach(file => {
|
||||||
|
formData.append('files', file);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/tracks/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
setUploadStatus({
|
||||||
|
success: result.success,
|
||||||
|
uploaded: result.uploaded.length,
|
||||||
|
errors: result.errors.length
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.uploaded.length > 0) {
|
||||||
|
onUploadComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => setUploadStatus(null), 5000);
|
||||||
|
} catch (error) {
|
||||||
|
setUploadStatus({
|
||||||
|
success: false,
|
||||||
|
uploaded: 0,
|
||||||
|
errors: 1
|
||||||
|
});
|
||||||
|
console.error('Upload failed:', error);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-800/50 backdrop-blur-sm rounded-xl p-6 border border-slate-700">
|
||||||
|
<h3 className="text-xl font-bold text-white mb-4">Upload Music</h3>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
||||||
|
isDragging
|
||||||
|
? 'border-purple-500 bg-purple-500/10'
|
||||||
|
: 'border-slate-600 hover:border-slate-500'
|
||||||
|
}`}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="audio/mpeg,.mp3"
|
||||||
|
multiple
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
id="file-upload"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="text-5xl mb-4">🎵</div>
|
||||||
|
|
||||||
|
{uploading ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-white mb-2">Uploading...</div>
|
||||||
|
<div className="w-full h-2 bg-slate-700 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-purple-600 animate-pulse" style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-white mb-2">
|
||||||
|
Drag and drop MP3 files here
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-400 text-sm mb-4">or</p>
|
||||||
|
<label
|
||||||
|
htmlFor="file-upload"
|
||||||
|
className="inline-block px-6 py-3 bg-purple-600 hover:bg-purple-700 text-white rounded-lg cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
Browse Files
|
||||||
|
</label>
|
||||||
|
<p className="text-slate-500 text-xs mt-4">
|
||||||
|
Max 50MB per file, up to 20 files
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{uploadStatus && (
|
||||||
|
<div className={`mt-4 p-4 rounded-lg ${
|
||||||
|
uploadStatus.success ? 'bg-green-500/20 border border-green-500/50' : 'bg-red-500/20 border border-red-500/50'
|
||||||
|
}`}>
|
||||||
|
<p className={uploadStatus.success ? 'text-green-400' : 'text-red-400'}>
|
||||||
|
{uploadStatus.success
|
||||||
|
? `Successfully uploaded ${uploadStatus.uploaded} file(s)`
|
||||||
|
: `Upload failed: ${uploadStatus.errors} error(s)`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
web/src/hooks/useWebSocket.js
Normal file
74
web/src/hooks/useWebSocket.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
|
||||||
|
const WS_URL = import.meta.env.VITE_WS_URL || `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
|
||||||
|
|
||||||
|
export default function useWebSocket(onMessage) {
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const wsRef = useRef(null);
|
||||||
|
const reconnectTimeoutRef = useRef(null);
|
||||||
|
const onMessageRef = useRef(onMessage);
|
||||||
|
|
||||||
|
// Keep the callback ref up to date
|
||||||
|
useEffect(() => {
|
||||||
|
onMessageRef.current = onMessage;
|
||||||
|
}, [onMessage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const connect = () => {
|
||||||
|
try {
|
||||||
|
const ws = new WebSocket(WS_URL);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
setConnected(true);
|
||||||
|
ws.send(JSON.stringify({ event: 'subscribe' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
if (message.event && message.event !== 'connected') {
|
||||||
|
onMessageRef.current(message.event, message.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse WebSocket message:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('WebSocket disconnected');
|
||||||
|
setConnected(false);
|
||||||
|
wsRef.current = null;
|
||||||
|
|
||||||
|
// Reconnect after 3 seconds
|
||||||
|
reconnectTimeoutRef.current = setTimeout(() => {
|
||||||
|
connect();
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
ws.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
wsRef.current = ws;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create WebSocket:', error);
|
||||||
|
reconnectTimeoutRef.current = setTimeout(connect, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
}
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { connected };
|
||||||
|
}
|
||||||
14
web/src/index.css
Normal file
14
web/src/index.css
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #f1f5f9;
|
||||||
|
}
|
||||||
10
web/src/main.jsx
Normal file
10
web/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
10
web/tailwind.config.js
Normal file
10
web/tailwind.config.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
20
web/vite.config.js
Normal file
20
web/vite.config.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://localhost:3001',
|
||||||
|
ws: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user