NodeManager
Manages all Lavalink nodes. Handles WebSocket connections, exponential-backoff reconnection, session resuming, and weighted load balancing. Nodes are scored by CPU, memory, and player count.
Ryanlink is a Lavalink v4 wrapper built around four core systems: node management, player lifecycle, queue, and filter management.
NodeManager
Manages all Lavalink nodes. Handles WebSocket connections, exponential-backoff reconnection, session resuming, and weighted load balancing. Nodes are scored by CPU, memory, and player count.
Player
Per-guild audio player. Tracks voice state, playback position, filters, and queue. Supports auto-reconnect, smart-leave, auto-pause, node migration, and lifecycle hooks.
Queue + TrackRegistry
Each player has a Queue backed by a TrackRegistry. The registry stores track data once and references it by identifier, keeping memory usage low even for large queues.
FilterManager
Non-destructive filter stacking. All standard Lavalink filters plus NodeLink-exclusive filters (echo, chorus, compressor, highPass, phaser, spatial) and plugin filters (LavaDSPX, filter-engine).
nodeManager.createNode(options) adds a node and emits nodeCreate.node.connect() opens a WebSocket. On open, fetchInfo() is called to populate node.info and register source mappings.{ op: 'ready', sessionId, resumed }. nodeReady is emitted.nodeDisconnect is emitted. Reconnection begins with exponential backoff (base delay doubles each attempt, capped at 30s).resumed: true arrives in the ready payload and player states are restored.node.destroy(reason, deleteNode, movePlayers) closes the socket, optionally migrates players to another node, and removes the node from the manager.manager.createPlayer(options) creates a player and emits playerCreate. If a player already exists for the guild, it is returned.player.connect() sends a voice state update to Discord via sendToShard.manager.provideVoiceUpdate(payload) handles VOICE_STATE_UPDATE and VOICE_SERVER_UPDATE. Once both token and sessionId are received, Ryanlink sends a PATCH to Lavalink.player.play() sends a PATCH to Lavalink’s /sessions/{id}/players/{guildId} endpoint.trackStart, trackEnd, trackStuck, trackError are emitted as Lavalink sends them via WebSocket.queueEnd fires. onEmptyQueue.autoPlayFunction is called if set. destroyAfterMs countdown starts if configured.player.destroy() disconnects from voice, removes the player from the manager, calls node.destroyPlayer(guildId), and emits playerDestroy.When a node disconnects, Ryanlink can automatically migrate players. Use node.destroy(reason, deleteNode, movePlayers: true) which migrates players to the least-used connected node:
// Automatic migration on node destroynode.destroy('Maintenance', true, true);
// Manual migration via playerawait player.moveNode(); // auto-select least-used nodeawait player.moveNode('node-id'); // migrate to specific nodeawait player.changeNode(nodeInstance);Or listen to the disconnect event:
manager.nodeManager.on('nodeDisconnect', async (node, reason) => { const best = manager.nodeManager.leastUsedNodes('weighted')[0]; if (!best) return; for (const player of manager.players.values()) { if (player.node?.id === node.id) { await player.moveNode(best.id).catch(() => {}); } }});Nodes are scored using:
score = (cpu.systemLoad × 0.7) + (memory.used / memory.allocated × 0.2) + (players / 100 × 0.1)Lower score = healthier node. Disconnected nodes return Infinity. Use nodeManager.leastUsedNodes(metric):
| Metric | Sort by |
|---|---|
'weighted' | Composite score (recommended) |
'memory' | Least memory used |
'cpuLavalink' | Least audio CPU load |
'cpuSystem' | Least system CPU load |
'calls' | Fewest REST calls |
'playingPlayers' | Fewest playing players |
'players' | Fewest total players (default) |
Discord VOICE_STATE_UPDATE ──┐ ├──► manager.provideVoiceUpdate(payload)Discord VOICE_SERVER_UPDATE ──┘ │ ├── Updates player.voice (token, endpoint, sessionId, channelId) ├── Tracks voiceStates map for all users └── Sends PATCH to Lavalink /sessions/{id}/players/{guildId}Always forward raw Discord events:
client.on('raw', (packet) => { if (manager.initiated) manager.provideVoiceUpdate(packet);});Discord’s DAVE protocol sends VOICE_STATE_UPDATE with channel_id: null during the E2EE handshake even when the bot is actively joining. Ryanlink handles this automatically — player.voiceChannelId is preserved as a fallback so Lavalink always receives the correct channel ID. No configuration required.
Ryanlink uses a TrackEntry class that stores track data in a static TrackRegistry map, referenced by identifier (or encoded as fallback). Multiple TrackEntry instances for the same track share one registry entry.
import { AudioTrackSymbol, UnresolvedAudioTrackSymbol } from 'ryanlink';
// Check if a track is resolvedif (track[AudioTrackSymbol] === true) { // fully resolved Track}
// Check if a track is unresolvedif (track[UnresolvedAudioTrackSymbol] === true) { // UnresolvedTrack — call track.resolve(player) to resolve}Each node has its own SourceRegistry that maps search prefixes to source names and stores URL matchers. This is populated automatically from node.info.sourceManagers on connect, and can be extended:
// Register a custom prefix aliasnode.sourceRegistry.registerMapping('yt', 'ytsearch');
// Register a URL matcher for source detectionnode.sourceRegistry.registerMatcher('youtube', /youtube\.com|youtu\.be/);
// Register a pluginnode.sourceRegistry.registerPlugin('lavasrc', 'lavasrc-plugin');MiniMap<K, V> extends the native Map with filter(), map(), and toJSON() methods. Used internally for manager.players and nodeManager.nodes.
// Filter connected nodesconst connected = manager.nodeManager.nodes.filter(n => n.connected);
// Map to IDsconst ids = manager.nodeManager.nodes.map((node, id) => id);