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

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;