first attempt at code mostly working

This commit is contained in:
2025-12-12 21:30:26 -08:00
parent 7bc01a3828
commit a840e9385d
32 changed files with 2160 additions and 0 deletions

20
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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}`);
});

View 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;

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

169
web/src/App.jsx Normal file
View 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;

View 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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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
View 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
}
}
}
});