diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9662a73 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aaac669 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +node_modules/ +.env +*.log +.DS_Store +music/ +data/ +dist/ +build/ +.vscode/ +.idea/ +*.swp +*.swo +*~ +coverage/ +.nyc_output/ +tmp/ +uploads/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..67d674b --- /dev/null +++ b/README.md @@ -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 +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. diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..9100e22 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-slim + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . + +EXPOSE 3001 + +CMD ["npm", "start"] diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..9550ce4 --- /dev/null +++ b/api/package.json @@ -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" + } +} diff --git a/api/src/index.js b/api/src/index.js new file mode 100644 index 0000000..2355260 --- /dev/null +++ b/api/src/index.js @@ -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}`); +}); diff --git a/api/src/routes/channels.js b/api/src/routes/channels.js new file mode 100644 index 0000000..f28fcac --- /dev/null +++ b/api/src/routes/channels.js @@ -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; diff --git a/api/src/routes/playback.js b/api/src/routes/playback.js new file mode 100644 index 0000000..d1dd294 --- /dev/null +++ b/api/src/routes/playback.js @@ -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; diff --git a/api/src/routes/queue.js b/api/src/routes/queue.js new file mode 100644 index 0000000..f9ef82c --- /dev/null +++ b/api/src/routes/queue.js @@ -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; diff --git a/api/src/routes/status.js b/api/src/routes/status.js new file mode 100644 index 0000000..62723b2 --- /dev/null +++ b/api/src/routes/status.js @@ -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; diff --git a/api/src/routes/tracks.js b/api/src/routes/tracks.js new file mode 100644 index 0000000..52e8498 --- /dev/null +++ b/api/src/routes/tracks.js @@ -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; diff --git a/bot/Dockerfile b/bot/Dockerfile new file mode 100644 index 0000000..dd9bbe9 --- /dev/null +++ b/bot/Dockerfile @@ -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"] diff --git a/bot/package.json b/bot/package.json new file mode 100644 index 0000000..31a9b4f --- /dev/null +++ b/bot/package.json @@ -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" + } +} diff --git a/bot/src/index.js b/bot/src/index.js new file mode 100644 index 0000000..2e258b6 --- /dev/null +++ b/bot/src/index.js @@ -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); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e48a798 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/spec.md b/spec.md index be2d6b5..fcae36e 100644 --- a/spec.md +++ b/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 diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..829aedc --- /dev/null +++ b/web/Dockerfile @@ -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;"] diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..cbeebfb --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + Discord DJ + + +
+ + + diff --git a/web/nginx.conf b/web/nginx.conf new file mode 100644 index 0000000..9bd8495 --- /dev/null +++ b/web/nginx.conf @@ -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; + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..5170d76 --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/web/src/App.jsx b/web/src/App.jsx new file mode 100644 index 0000000..49ed028 --- /dev/null +++ b/web/src/App.jsx @@ -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 ( +
+
+
+

10node christmas bot

+

+ + {connected ? 'Connected' : 'Disconnected'} +

+
+ +
+
+ + + + + + + +
+ +
+ +
+
+
+
+ ); +} + +export default App; diff --git a/web/src/components/ChannelSelector.jsx b/web/src/components/ChannelSelector.jsx new file mode 100644 index 0000000..881c4d9 --- /dev/null +++ b/web/src/components/ChannelSelector.jsx @@ -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 ( +
+

Voice Channel

+ + {currentChannel && ( +
+
Currently in:
+
{currentChannel.name}
+ {currentChannel.userCount > 0 && ( +
+ {currentChannel.userCount} {currentChannel.userCount === 1 ? 'user' : 'users'} +
+ )} +
+ )} + +
+ {loading && channels.length === 0 ? ( +
Loading channels...
+ ) : channels.length === 0 ? ( +
No channels available
+ ) : ( + channels.map(channel => ( + + )) + )} +
+
+ ); +} + +export default ChannelSelector; diff --git a/web/src/components/Controls.jsx b/web/src/components/Controls.jsx new file mode 100644 index 0000000..7be7e5e --- /dev/null +++ b/web/src/components/Controls.jsx @@ -0,0 +1,59 @@ +export default function Controls({ state, volume, onPlay, onPause, onSkip, onVolumeChange, onShuffle }) { + return ( +
+
+
+ {state === 'playing' ? ( + + ) : ( + + )} + + + + +
+ +
+ 🔊 + 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%)` + }} + /> + {volume}% +
+
+
+ ); +} diff --git a/web/src/components/NowPlaying.jsx b/web/src/components/NowPlaying.jsx new file mode 100644 index 0000000..a809108 --- /dev/null +++ b/web/src/components/NowPlaying.jsx @@ -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 ( +
+
+
🎵
+

No track playing

+
+
+ ); + } + + return ( +
+
+
+ 🎵 +
+ +
+
Now Playing
+

{track.title}

+

{track.artist}

+ +
+ {formatTime(displayPosition)} +
+
0 ? `${(displayPosition / track.duration) * 100}%` : '0%' }} + /> +
+ {formatTime(track.duration || 0)} +
+
+ +
+
+ {state === 'playing' ? '▶ Playing' : state === 'paused' ? '⏸ Paused' : '⏹ Stopped'} +
+
+
+
+ ); +} diff --git a/web/src/components/TrackList.jsx b/web/src/components/TrackList.jsx new file mode 100644 index 0000000..3a742cf --- /dev/null +++ b/web/src/components/TrackList.jsx @@ -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 ( +
+

Library

+
Loading...
+
+ ); + } + + if (tracks.length === 0) { + return ( +
+

Library

+
+

No tracks in library

+

Upload some MP3 files to get started

+
+
+ ); + } + + return ( +
+

+ Library ({tracks.length} tracks) +

+ +
+ {tracks.map((track) => ( +
+
+
+
{track.title}
+
{track.artist}
+
+ {track.duration > 0 && {formatDuration(track.duration)}} + {track.size && {formatFileSize(track.size)}} +
+
+ + +
+
+ ))} +
+
+ ); +} diff --git a/web/src/components/UploadZone.jsx b/web/src/components/UploadZone.jsx new file mode 100644 index 0000000..c3ce9ca --- /dev/null +++ b/web/src/components/UploadZone.jsx @@ -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 ( +
+

Upload Music

+ +
+ + +
🎵
+ + {uploading ? ( +
+
Uploading...
+
+
+
+
+ ) : ( + <> +

+ Drag and drop MP3 files here +

+

or

+ +

+ Max 50MB per file, up to 20 files +

+ + )} +
+ + {uploadStatus && ( +
+

+ {uploadStatus.success + ? `Successfully uploaded ${uploadStatus.uploaded} file(s)` + : `Upload failed: ${uploadStatus.errors} error(s)`} +

+
+ )} +
+ ); +} diff --git a/web/src/hooks/useWebSocket.js b/web/src/hooks/useWebSocket.js new file mode 100644 index 0000000..85a6e8e --- /dev/null +++ b/web/src/hooks/useWebSocket.js @@ -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 }; +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..eeb37d3 --- /dev/null +++ b/web/src/index.css @@ -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; +} diff --git a/web/src/main.jsx b/web/src/main.jsx new file mode 100644 index 0000000..47742c1 --- /dev/null +++ b/web/src/main.jsx @@ -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( + + + +); diff --git a/web/tailwind.config.js b/web/tailwind.config.js new file mode 100644 index 0000000..3718949 --- /dev/null +++ b/web/tailwind.config.js @@ -0,0 +1,10 @@ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/web/vite.config.js b/web/vite.config.js new file mode 100644 index 0000000..3ebf203 --- /dev/null +++ b/web/vite.config.js @@ -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 + } + } + } +});