nebula-client-js
v1.5.0
Published
A discord.js client for NebulaAudio, styled after wavelink/lavalink.js
Maintainers
Readme
nebula-client
A discord.js client for NebulaAudio, styled after wavelink / lavalink.js:
connect a Node, use Player alongside @discordjs/voice, await
node.search(...) to load tracks, await player.play(track) to play them.
Install
npm i -g nebula-client-js
npm i discord.js @discordjs/voice # peer deps, install in your bot projectRepository: https://github.com/ItzAntonio101/nebula-client-js
Status — read before wiring this into a real bot
Verified working (tested against a real local HTTP server and direct event-emission calls in this environment — not just syntax-checked):
Node.search(),fetchInfo(),fetchStats(), error propagation (LoadTrackErroronloadType: "error") — confirmed against a real HTTP server simulating NebulaAudio's/v4/loadtracksand/v4/inforesponses.- WebSocket message handling (
ready,playerUpdate,TrackStartEvent,TrackEndEvent,TrackExceptionEvent,TrackStuckEvent) — confirmed by feeding realistic payloads intoNode._handleMessage()directly and checking emitted events. Track,Playlist,SearchResult,Filters(including equalizer validation) — confirmed with realistic NebulaAudio JSON shapes.
Not runtime-testable in the environment this was built in (no npm
registry access, so discord.js/@discordjs/voice/ws couldn't be
installed) — only syntax-checked with node --check:
Player.js's@discordjs/voiceintegration (joinVoiceChannel,entersState,VoiceConnectionStatus.Ready). The API shapes used match@discordjs/voice's documented, stable interface as I recall it, but this has not been exercised against the real library. Test this path first when you install real dependencies.- The actual
wsWebSocket transport inNode.js(connect(), reconnect/backoff) — the message-handling logic it feeds into (above) is verified, but the socket plumbing itself wasn't exercised against a realwsserver.
The audio-transport gap (same root cause as the Java server):
NebulaAudio's server does not implement Discord's voice UDP/RTP transmission
yet — AudioPlayer.sendFrame() on the server is a documented no-op seam.
That means player.play(track) here correctly tells the node to start
decoding, and you'll get real trackStart/trackEnd events — but no Opus
audio currently reaches the @discordjs/voice connection this client sets
up. Player.prototype._wireVoiceTransport() is the exact spot where that
would connect once the server side exists; it currently does nothing rather
than pretending to.
Example bot
const { Client, GatewayIntentBits } = require('discord.js');
const { Node, NodePool, Player, Filters } = require('nebula-client');
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
const players = new Map(); // guildId -> Player
client.once('ready', async () => {
const node = new Node({ uri: 'http://localhost:2333', password: 'supersecret' });
node.on('ready', (resumed, sessionId) => console.log(`Node ready (resumed=${resumed}, session=${sessionId})`));
node.on('error', (err) => console.error('Node error:', err));
await NodePool.connect([node], client.user.id);
console.log(`Logged in as ${client.user.tag}`);
});
client.on('messageCreate', async (message) => {
if (message.author.bot || !message.content.startsWith('!')) return;
const [cmd, ...rest] = message.content.slice(1).split(' ');
const query = rest.join(' ');
if (cmd === 'play') {
const voiceChannel = message.member?.voice?.channel;
if (!voiceChannel) return message.reply('Join a voice channel first.');
let player = players.get(message.guildId);
if (!player) {
player = new Player({ guild: message.guild, channel: voiceChannel });
player.on('trackStart', (track) => message.channel.send(`Now playing: **${track.title}**`));
player.on('trackException', (track, msg) => message.channel.send(`Playback error: ${msg}`));
await player.connect();
players.set(message.guildId, player);
}
const identifier = query.includes('://') || query.includes(':') ? query : `ytsearch:${query}`;
const result = await player.node.search(identifier);
if (!result.length) return message.reply('Nothing found.');
const track = result.tracks[0];
track.requester = message.author;
if (player.isPlaying) {
player.addToQueue(track);
message.channel.send(`Queued: **${track.title}**`);
} else {
await player.play(track);
}
}
if (cmd === 'skip') {
const player = players.get(message.guildId);
if (player) { await player.skip(); message.reply('Skipped.'); }
}
if (cmd === 'volume') {
const player = players.get(message.guildId);
if (player) { await player.setVolume(Number(rest[0]) || 100); message.reply(`Volume set.`); }
}
if (cmd === 'nightcore') {
const player = players.get(message.guildId);
if (player) { await player.setFilters(new Filters().setNightcore()); message.reply('Nightcore on.'); }
}
});
client.login('YOUR_TOKEN');Layout
src/
├── index.js - public exports
├── Node.js - REST + WebSocket connection manager
├── NodePool.js - static registry of connected Nodes
├── Player.js - per-guild player wrapping @discordjs/voice
├── Track.js - Track / Playlist / SearchResult
├── Filters.js - chainable filters builder
├── enums.js - LoadType, TrackEndReason, NodeStatus
└── errors.js - error class hierarchy