Skip to content

Architecture Overview

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


  1. CreatenodeManager.createNode(options) adds a node and emits nodeCreate.
  2. Connectnode.connect() opens a WebSocket. On open, fetchInfo() is called to populate node.info and register source mappings.
  3. Ready — Lavalink sends { op: 'ready', sessionId, resumed }. nodeReady is emitted.
  4. Stats — Lavalink sends stats every 30s. Node scores are updated for load balancing. Heartbeat is triggered.
  5. Disconnect — Socket closes. nodeDisconnect is emitted. Reconnection begins with exponential backoff (base delay doubles each attempt, capped at 30s).
  6. Resume — If session is still valid, resumed: true arrives in the ready payload and player states are restored.
  7. Destroynode.destroy(reason, deleteNode, movePlayers) closes the socket, optionally migrates players to another node, and removes the node from the manager.

  1. Createmanager.createPlayer(options) creates a player and emits playerCreate. If a player already exists for the guild, it is returned.
  2. Connectplayer.connect() sends a voice state update to Discord via sendToShard.
  3. Voice syncmanager.provideVoiceUpdate(payload) handles VOICE_STATE_UPDATE and VOICE_SERVER_UPDATE. Once both token and sessionId are received, Ryanlink sends a PATCH to Lavalink.
  4. Playplayer.play() sends a PATCH to Lavalink’s /sessions/{id}/players/{guildId} endpoint.
  5. Track eventstrackStart, trackEnd, trackStuck, trackError are emitted as Lavalink sends them via WebSocket.
  6. Queue end — When no more tracks, queueEnd fires. onEmptyQueue.autoPlayFunction is called if set. destroyAfterMs countdown starts if configured.
  7. Destroyplayer.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 destroy
node.destroy('Maintenance', true, true);
// Manual migration via player
await player.moveNode(); // auto-select least-used node
await player.moveNode('node-id'); // migrate to specific node
await 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):

MetricSort 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 resolved
if (track[AudioTrackSymbol] === true) {
// fully resolved Track
}
// Check if a track is unresolved
if (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 alias
node.sourceRegistry.registerMapping('yt', 'ytsearch');
// Register a URL matcher for source detection
node.sourceRegistry.registerMatcher('youtube', /youtube\.com|youtu\.be/);
// Register a plugin
node.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 nodes
const connected = manager.nodeManager.nodes.filter(n => n.connected);
// Map to IDs
const ids = manager.nodeManager.nodes.map((node, id) => id);