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