Discord Bot Setup Guide

Follow these steps to get your Nyuki-powered 24/7 live radio bot running. This guide assumes you have a basic understanding of Discord and the Nyuki API.

Step 1: Project Setup & Dependencies

Create a folder for your bot and install all necessary packages.

npm install discord.js @discordjs/voice axios dotenv ffmpeg-static libsodium-wrappers

Step 2: Credentials

Create a file named .env in your project folder and add your credentials:

DISCORD_BOT_TOKEN=your_bot_token_here
NYUKI_API_KEY=your_nyuki_api_key_here
BOT_PREFIX=!

Step 3: Example Bot Code

Create a file named bot.js and paste the complete code below. This version correctly handles the live `seek` time from the API to ensure all listeners are synchronized.


require('dotenv').config();
const { Client, GatewayIntentBits, Partials } = require('discord.js');
const {
    joinVoiceChannel,
    createAudioPlayer,
    createAudioResource,
    VoiceConnectionStatus,
    AudioPlayerStatus,
    StreamType,
} = require('@discordjs/voice');
const axios = require('axios');

// --- Configuration ---
const BOT_TOKEN = process.env.DISCORD_BOT_TOKEN;
const API_KEY = process.env.NYUKI_API_KEY;
const PREFIX = process.env.BOT_PREFIX || '!';
const NYUKI_API_URL = 'https://nyukimusic.top/api';

// --- Discord Client Setup ---
const client = new Client({
    intents: [
        GatewayIntentBits.Guilds,
        GatewayIntentBits.GuildMessages,
        GatewayIntentBits.MessageContent,
        GatewayIntentBits.GuildVoiceStates,
    ],
    partials: [Partials.Channel],
});

// --- State Management ---
const guildPlayers = new Map();

// --- Axios Instance for API calls ---
const api = axios.create({
    baseURL: NYUKI_API_URL,
    headers: {
        'Authorization': API_KEY,
        'User-Agent': 'Nyuki-Discord-Bot/4.0 (Node.js; 24/7 Live)',
    },
});

// --- Bot Ready Event ---
client.on('ready', () => {
    console.log(`[READY] Logged in as ${client.user.tag}`);
    console.log(`[INFO] Ready to stream 24/7 radio.`);
});

// --- Message Handler ---
client.on('messageCreate', async (message) => {
    if (message.author.bot || !message.content.startsWith(PREFIX)) return;

    const args = message.content.slice(PREFIX.length).trim().split(/ +/);
    const command = args.shift().toLowerCase();

    // --- Command: !play  ---
    if (command === 'play') {
        const voiceChannel = message.member.voice.channel;
        if (!voiceChannel) return message.channel.send('You need to be in a voice channel to play music!');
        
        const station = args[0];
        if (!station) return message.channel.send(`Please provide a station. Usage: \`${PREFIX}play lofi\``);

        if (guildPlayers.has(message.guild.id)) {
            await stopPlayback(message.guild.id);
        }

        const loadingMessage = await message.channel.send(`đŸŽĩ Tuning into the \`${station}\` station...`);

        try {
            const connection = joinVoiceChannel({
                channelId: voiceChannel.id,
                guildId: voiceChannel.guild.id,
                adapterCreator: voiceChannel.guild.voiceAdapterCreator,
            });
            const player = createAudioPlayer();
            connection.subscribe(player);
            guildPlayers.set(message.guild.id, { connection, player, station });

            connection.once(VoiceConnectionStatus.Ready, async () => {
                console.log(`[VC-CONNECT] Successfully connected to voice channel: ${voiceChannel.name}`);
                await api.post('/session/start', { station });
                loadingMessage.edit(`✅ Now live with the \`${station}\` station in **${voiceChannel.name}**!`);
                playLive(message.guild.id, station); // Start the 24/7 loop
            });

            // This is the core of the 24/7 feature.
            player.on(AudioPlayerStatus.Idle, () => {
                console.log(`[IDLE] Track finished for guild ${message.guild.id}. Fetching next live track automatically...`);
                // When one song ends, immediately get the next one.
                playLive(message.guild.id, station);
            });

            player.on('error', error => {
                console.error(`[PLAYER-ERROR] Audio player error for guild ${message.guild.id}:`, error.message);
                // Attempt to recover by fetching the next track after a short delay
                setTimeout(() => playLive(message.guild.id, station), 5000);
            });

        } catch (error) {
            console.error('[ERROR] An error occurred in the !play command:', error);
            const errorMsg = error.response?.data?.error || 'Could not connect to the Nyuki API.';
            loadingMessage.edit(`❌ **Error:** ${errorMsg}`);
            await stopPlayback(message.guild.id); // Clean up on failure
        }
    }

    // --- Command: !stop ---
    if (command === 'stop') {
        if (guildPlayers.has(message.guild.id)) {
            await stopPlayback(message.guild.id);
            message.channel.send('âšī¸ Radio stopped. Thanks for listening!');
        }
    }
});

/**
 * The core function to fetch and play a live track.
 * @param {string} guildId The ID of the guild.
 * @param {string} station The station name.
 */
async function playLive(guildId, station) {
    const guildData = guildPlayers.get(guildId);
    if (!guildData) {
        console.log(`[WARN] playLive called for guild ${guildId}, but no player was found. Aborting loop.`);
        return;
    }
    
    try {
        console.log(`[API-CALL] Fetching live track for station '${station}'...`);
        const { data: streamInfo } = await api.get(`/stream/${station}`);
        
        const seekSeconds = parseFloat(streamInfo.seek);
        console.log(`[API-RESPONSE] Received stream URL: ${streamInfo.streamUrl}`);
        console.log(`[FFMPEG] Will start playback at ${seekSeconds.toFixed(2)} seconds.`);

        // **THE CRITICAL FIX IS HERE**
        // This ensures FFmpeg seeks to the live time provided by the API.
        // The bot has no way to override this.
        const resource = createAudioResource(streamInfo.streamUrl, {
            ffmpegArgs: ['-ss', `${seekSeconds}s`],
            inputType: StreamType.FFmpeg,
        });

        guildData.player.play(resource);
        console.log(`[PLAYING] Streaming live from station '${station}'.`);
    } catch (error) {
        const errorMsg = error.response?.data?.error || "Connection to API failed.";
        console.error(`[ERROR] Failed to fetch next track for station '${station}':`, errorMsg);
        
        if (guildData.player.state.status !== AudioPlayerStatus.Playing) {
            console.warn(`[RETRY] Player is not running. Retrying fetch in 5 seconds...`);
            setTimeout(() => playLive(guildId, station), 5000);
        }
    }
}

/**
 * Stops playback and cleans up resources for a guild.
 * @param {string} guildId The ID of the guild.
 */
async function stopPlayback(guildId) {
    const guildData = guildPlayers.get(guildId);
    if (guildData) {
        console.log(`[STOP] Stopping playback and destroying connection for guild: ${guildId}`);
        guildData.player.stop(true);
        guildData.connection.destroy();
        guildPlayers.delete(guildId);
        try {
            await api.post('/session/stop');
            console.log('[API-CALL] Successfully notified API of session stop.');
        } catch (error) {
            console.error('[ERROR] Failed to notify API about session stop:', error.response?.data?.error);
        }
    }
}

client.login(BOT_TOKEN);