345 lines
9.4 KiB
JavaScript
345 lines
9.4 KiB
JavaScript
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);
|