first attempt at code mostly working
This commit is contained in:
12
api/Dockerfile
Normal file
12
api/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:20-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["npm", "start"]
|
||||
19
api/package.json
Normal file
19
api/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "music-bot-api",
|
||||
"version": "1.0.0",
|
||||
"description": "API server for Discord music bot",
|
||||
"main": "src/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.2",
|
||||
"cors": "^2.8.5",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"music-metadata": "^10.5.0",
|
||||
"ws": "^8.18.0",
|
||||
"winston": "^3.17.0"
|
||||
}
|
||||
}
|
||||
109
api/src/index.js
Normal file
109
api/src/index.js
Normal file
@@ -0,0 +1,109 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import http from 'http';
|
||||
import { createLogger, format, transports } from 'winston';
|
||||
import tracksRouter from './routes/tracks.js';
|
||||
import playbackRouter from './routes/playback.js';
|
||||
import queueRouter from './routes/queue.js';
|
||||
import statusRouter from './routes/status.js';
|
||||
import channelsRouter from './routes/channels.js';
|
||||
|
||||
const logger = createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: format.combine(
|
||||
format.timestamp(),
|
||||
format.printf(({ timestamp, level, message }) => `${timestamp} [${level}]: ${message}`)
|
||||
),
|
||||
transports: [new transports.Console()]
|
||||
});
|
||||
|
||||
const API_PORT = process.env.API_PORT || 3001;
|
||||
const BOT_INTERNAL_URL = process.env.BOT_INTERNAL_URL || 'http://bot:8080';
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Make bot URL and logger available to routes
|
||||
app.locals.botUrl = BOT_INTERNAL_URL;
|
||||
app.locals.logger = logger;
|
||||
app.locals.wss = wss;
|
||||
|
||||
// Routes
|
||||
app.use('/api/tracks', tracksRouter);
|
||||
app.use('/api/playback', playbackRouter);
|
||||
app.use('/api/queue', queueRouter);
|
||||
app.use('/api/status', statusRouter);
|
||||
app.use('/api/channels', channelsRouter);
|
||||
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
// WebSocket connection handling
|
||||
wss.on('connection', (ws) => {
|
||||
logger.info('WebSocket client connected');
|
||||
|
||||
ws.on('message', (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
if (data.event === 'subscribe') {
|
||||
logger.info('Client subscribed to updates');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`WebSocket message error: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
logger.info('WebSocket client disconnected');
|
||||
});
|
||||
|
||||
// Send initial connection confirmation
|
||||
ws.send(JSON.stringify({ event: 'connected' }));
|
||||
});
|
||||
|
||||
// Broadcast to all WebSocket clients
|
||||
export function broadcast(event, data) {
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === 1) {
|
||||
client.send(JSON.stringify({ event, data }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.locals.broadcast = broadcast;
|
||||
|
||||
// Poll bot state and broadcast updates
|
||||
async function pollBotState() {
|
||||
try {
|
||||
const response = await fetch(`${BOT_INTERNAL_URL}/state`);
|
||||
if (response.ok) {
|
||||
const state = await response.json();
|
||||
broadcast('playbackUpdate', {
|
||||
state: state.state,
|
||||
position: state.position,
|
||||
volume: state.volume
|
||||
});
|
||||
if (state.currentTrack) {
|
||||
broadcast('trackChange', {
|
||||
track: state.currentTrack,
|
||||
queueLength: state.queueLength
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Bot might not be ready yet
|
||||
}
|
||||
}
|
||||
|
||||
// Poll every 2 seconds
|
||||
setInterval(pollBotState, 2000);
|
||||
|
||||
server.listen(API_PORT, () => {
|
||||
logger.info(`API server listening on port ${API_PORT}`);
|
||||
});
|
||||
39
api/src/routes/channels.js
Normal file
39
api/src/routes/channels.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import express from 'express';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get all voice channels
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const response = await fetch(`${req.app.locals.botUrl}/channels`);
|
||||
const result = await response.json();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
req.app.locals.logger.error(`Error getting channels: ${error.message}`);
|
||||
res.status(500).json({ error: 'Failed to get channels' });
|
||||
}
|
||||
});
|
||||
|
||||
// Join a voice channel
|
||||
router.post('/join', async (req, res) => {
|
||||
try {
|
||||
const { channelId } = req.body;
|
||||
const response = await fetch(`${req.app.locals.botUrl}/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ channelId })
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
req.app.locals.broadcast('channelChange', { channelId, channelName: result.channelName });
|
||||
}
|
||||
|
||||
res.status(response.status).json(result);
|
||||
} catch (error) {
|
||||
req.app.locals.logger.error(`Error joining channel: ${error.message}`);
|
||||
res.status(500).json({ error: 'Failed to join channel' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
73
api/src/routes/playback.js
Normal file
73
api/src/routes/playback.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import express from 'express';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get current playback state
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const response = await fetch(`${req.app.locals.botUrl}/state`);
|
||||
const state = await response.json();
|
||||
res.json(state);
|
||||
} catch (error) {
|
||||
req.app.locals.logger.error(`Error getting playback state: ${error.message}`);
|
||||
res.status(500).json({ error: 'Failed to get playback state' });
|
||||
}
|
||||
});
|
||||
|
||||
// Play
|
||||
router.post('/play', async (req, res) => {
|
||||
try {
|
||||
const response = await fetch(`${req.app.locals.botUrl}/play`, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
req.app.locals.broadcast('playbackUpdate', { state: 'playing' });
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
req.app.locals.logger.error(`Error resuming playback: ${error.message}`);
|
||||
res.status(500).json({ error: 'Failed to resume playback' });
|
||||
}
|
||||
});
|
||||
|
||||
// Pause
|
||||
router.post('/pause', async (req, res) => {
|
||||
try {
|
||||
const response = await fetch(`${req.app.locals.botUrl}/pause`, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
req.app.locals.broadcast('playbackUpdate', { state: 'paused' });
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
req.app.locals.logger.error(`Error pausing playback: ${error.message}`);
|
||||
res.status(500).json({ error: 'Failed to pause playback' });
|
||||
}
|
||||
});
|
||||
|
||||
// Skip to next track
|
||||
router.post('/skip', async (req, res) => {
|
||||
try {
|
||||
const response = await fetch(`${req.app.locals.botUrl}/skip`, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
req.app.locals.logger.error(`Error skipping track: ${error.message}`);
|
||||
res.status(500).json({ error: 'Failed to skip track' });
|
||||
}
|
||||
});
|
||||
|
||||
// Set volume
|
||||
router.post('/volume', async (req, res) => {
|
||||
try {
|
||||
const { volume } = req.body;
|
||||
const response = await fetch(`${req.app.locals.botUrl}/volume`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ volume })
|
||||
});
|
||||
const result = await response.json();
|
||||
req.app.locals.broadcast('volumeChange', { volume });
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
req.app.locals.logger.error(`Error setting volume: ${error.message}`);
|
||||
res.status(500).json({ error: 'Failed to set volume' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
36
api/src/routes/queue.js
Normal file
36
api/src/routes/queue.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import express from 'express';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get current queue
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const response = await fetch(`${req.app.locals.botUrl}/state`);
|
||||
const state = await response.json();
|
||||
res.json({
|
||||
tracks: state.queue || [],
|
||||
currentTrack: state.currentTrack,
|
||||
shuffled: state.shuffled || false
|
||||
});
|
||||
} catch (error) {
|
||||
req.app.locals.logger.error(`Error getting queue: ${error.message}`);
|
||||
res.status(500).json({ error: 'Failed to get queue' });
|
||||
}
|
||||
});
|
||||
|
||||
// Shuffle queue
|
||||
router.post('/shuffle', async (req, res) => {
|
||||
try {
|
||||
const response = await fetch(`${req.app.locals.botUrl}/queue/shuffle`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const result = await response.json();
|
||||
req.app.locals.broadcast('queueUpdate', { shuffled: result.shuffled });
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
req.app.locals.logger.error(`Error shuffling queue: ${error.message}`);
|
||||
res.status(500).json({ error: 'Failed to shuffle queue' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
23
api/src/routes/status.js
Normal file
23
api/src/routes/status.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import express from 'express';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get bot status
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const response = await fetch(`${req.app.locals.botUrl}/health`);
|
||||
const health = await response.json();
|
||||
res.json({
|
||||
botConnected: health.connected,
|
||||
status: health.status
|
||||
});
|
||||
} catch (error) {
|
||||
res.json({
|
||||
botConnected: false,
|
||||
status: 'error',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
204
api/src/routes/tracks.js
Normal file
204
api/src/routes/tracks.js
Normal file
@@ -0,0 +1,204 @@
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { parseFile } from 'music-metadata';
|
||||
|
||||
const router = express.Router();
|
||||
const MUSIC_DIRECTORY = process.env.MUSIC_DIRECTORY || '/music';
|
||||
const MAX_UPLOAD_SIZE_MB = parseInt(process.env.MAX_UPLOAD_SIZE_MB || '50');
|
||||
const MAX_BATCH_SIZE = parseInt(process.env.MAX_BATCH_SIZE || '20');
|
||||
|
||||
// Ensure music directory exists
|
||||
if (!fs.existsSync(MUSIC_DIRECTORY)) {
|
||||
fs.mkdirSync(MUSIC_DIRECTORY, { recursive: true });
|
||||
}
|
||||
|
||||
// Configure multer for file uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, MUSIC_DIRECTORY);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
// Sanitize filename
|
||||
const sanitized = file.originalname
|
||||
.replace(/[^a-zA-Z0-9.-_]/g, '_')
|
||||
.replace(/_+/g, '_');
|
||||
|
||||
// Handle duplicates
|
||||
const handleDuplicate = process.env.DUPLICATE_HANDLING || 'rename';
|
||||
let finalName = sanitized;
|
||||
|
||||
if (handleDuplicate === 'rename') {
|
||||
let counter = 1;
|
||||
const ext = path.extname(sanitized);
|
||||
const base = path.basename(sanitized, ext);
|
||||
|
||||
while (fs.existsSync(path.join(MUSIC_DIRECTORY, finalName))) {
|
||||
finalName = `${base}_${counter}${ext}`;
|
||||
counter++;
|
||||
}
|
||||
}
|
||||
|
||||
cb(null, finalName);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: {
|
||||
fileSize: MAX_UPLOAD_SIZE_MB * 1024 * 1024,
|
||||
files: MAX_BATCH_SIZE
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype !== 'audio/mpeg' && !file.originalname.toLowerCase().endsWith('.mp3')) {
|
||||
return cb(new Error('Only MP3 files are allowed'));
|
||||
}
|
||||
cb(null, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Get all tracks
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const files = fs.readdirSync(MUSIC_DIRECTORY);
|
||||
const tracks = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.toLowerCase().endsWith('.mp3')) continue;
|
||||
|
||||
const filepath = path.join(MUSIC_DIRECTORY, file);
|
||||
const stats = fs.statSync(filepath);
|
||||
|
||||
let metadata = {
|
||||
title: file.replace('.mp3', ''),
|
||||
artist: 'Unknown',
|
||||
album: '',
|
||||
duration: 0
|
||||
};
|
||||
|
||||
try {
|
||||
const parsed = await parseFile(filepath);
|
||||
metadata = {
|
||||
title: parsed.common.title || metadata.title,
|
||||
artist: parsed.common.artist || metadata.artist,
|
||||
album: parsed.common.album || '',
|
||||
duration: Math.floor(parsed.format.duration || 0)
|
||||
};
|
||||
} catch (error) {
|
||||
// Use defaults if metadata parsing fails
|
||||
}
|
||||
|
||||
tracks.push({
|
||||
id: file,
|
||||
filename: file,
|
||||
filepath: file,
|
||||
...metadata,
|
||||
hasArt: false,
|
||||
size: stats.size
|
||||
});
|
||||
}
|
||||
|
||||
res.json(tracks);
|
||||
} catch (error) {
|
||||
req.app.locals.logger.error(`Error listing tracks: ${error.message}`);
|
||||
res.status(500).json({ error: 'Failed to list tracks' });
|
||||
}
|
||||
});
|
||||
|
||||
// Upload tracks
|
||||
router.post('/upload', upload.array('files', MAX_BATCH_SIZE), async (req, res) => {
|
||||
const uploaded = [];
|
||||
const errors = [];
|
||||
|
||||
if (!req.files || req.files.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
uploaded: [],
|
||||
errors: [{ error: 'No files provided' }]
|
||||
});
|
||||
}
|
||||
|
||||
for (const file of req.files) {
|
||||
try {
|
||||
const filepath = path.join(MUSIC_DIRECTORY, file.filename);
|
||||
const parsed = await parseFile(filepath);
|
||||
|
||||
uploaded.push({
|
||||
id: file.filename,
|
||||
filename: file.filename,
|
||||
title: parsed.common.title || file.filename.replace('.mp3', ''),
|
||||
artist: parsed.common.artist || 'Unknown',
|
||||
duration: Math.floor(parsed.format.duration || 0)
|
||||
});
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
filename: file.originalname,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Notify bot to reload library
|
||||
try {
|
||||
await fetch(`${req.app.locals.botUrl}/reload`, { method: 'POST' });
|
||||
} catch (error) {
|
||||
req.app.locals.logger.warn('Failed to notify bot of library update');
|
||||
}
|
||||
|
||||
// Broadcast library update
|
||||
req.app.locals.broadcast('libraryUpdate', {
|
||||
action: 'added',
|
||||
tracks: uploaded
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: errors.length === 0,
|
||||
uploaded,
|
||||
errors
|
||||
});
|
||||
});
|
||||
|
||||
// Delete track
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const filepath = path.join(MUSIC_DIRECTORY, req.params.id);
|
||||
|
||||
if (!fs.existsSync(filepath)) {
|
||||
return res.status(404).json({ error: 'Track not found' });
|
||||
}
|
||||
|
||||
fs.unlinkSync(filepath);
|
||||
|
||||
// Notify bot to reload library
|
||||
try {
|
||||
await fetch(`${req.app.locals.botUrl}/reload`, { method: 'POST' });
|
||||
} catch (error) {
|
||||
req.app.locals.logger.warn('Failed to notify bot of library update');
|
||||
}
|
||||
|
||||
// Broadcast library update
|
||||
req.app.locals.broadcast('libraryUpdate', {
|
||||
action: 'removed',
|
||||
trackIds: [req.params.id]
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
req.app.locals.logger.error(`Error deleting track: ${error.message}`);
|
||||
res.status(500).json({ error: 'Failed to delete track' });
|
||||
}
|
||||
});
|
||||
|
||||
// Rescan library
|
||||
router.post('/scan', async (req, res) => {
|
||||
try {
|
||||
await fetch(`${req.app.locals.botUrl}/reload`, { method: 'POST' });
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
req.app.locals.logger.error(`Error rescanning library: ${error.message}`);
|
||||
res.status(500).json({ error: 'Failed to rescan library' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user