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
|
||||
|
||||
- **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