Skip to content

Repository files navigation

🎵 Junie

A production-grade, developer-first Lavalink v4 client for Node.js & TypeScript.

Elegant audio orchestration for Discord bots.

Node.js TypeScript Lavalink License: MIT Tests

Quick start · Documentation · Examples · API tour


Junie wraps the Lavalink v4 protocol — WebSocket sessions, REST player control, voice credential routing, session resuming, multi-node load balancing — in a small, type-safe, thoroughly tested library with excellent developer experience as its first design goal.

Discord libraries come and go; Junie is library-agnostic. It talks to Lavalink and to your shards through one small sendToShard callback, and consumes raw gateway packets through one sendRawData method. Wire it to discord.js, Eris, DJS proxy bots, or a hand-rolled sharder — Junie doesn't care.

import { Junie } from 'junie';

const junie = new Junie({
  nodes: [{ id: 'main', host: 'localhost', authorization: 'youshallnotpass' }],
  sendToShard: (guildId, payload) => client.guilds.cache.get(guildId)?.shard.send(payload),
});

junie.init(client.user.id);
client.on('raw', (packet) => junie.sendRawData(packet));

client.on('interactionCreate', async (interaction) => {
  if (!interaction.isChatInputCommand()) return;
  if (interaction.commandName !== 'play') return;

  const player = junie.createPlayer({
    guildId: interaction.guildId,
    voiceChannelId: interaction.member.voice.channelId,
    textChannelId: interaction.channelId,
  });
  await player.connect();

  const result = await junie.search(interaction.options.getString('query'), interaction.user);
  player.queue.add(result.tracks);
  if (!player.playing) await player.play();

  await interaction.reply(`Now playing **${result.tracks[0]!.title}** 🎶`);
});

That's a working music command. Everything below is what makes Junie production-grade.


Why Junie?

Junie
Protocol Complete Lavalink v4: REST player control, WebSocket events, session resuming, DAVE-ready voice payloads (channelId always forwarded)
Reliability Exponential backoff with jitter, session-404 self-healing, voice self-rejoin, zombie-proof player destruction (a dead node can never wedge a guild)
Scale Penalty-based load balancing (players · CPU · frame loss · region), region-aware node placement, parallel search fan-out to defeat upstream rate limits
DX Fully typed events, requester typing that flows through tracks → queues → players, fluent filter chains, one-call presets
Queue Repeat modes, bounded history, shuffle/move/jump, autoplay, pluggable persistence (Redis/Postgres/...) with UnresolvedTrack lazy resolution
Ops Leveled structured logging, raw-payload event for telemetry, per-node stats, custom WebSocket transport for proxies

Feature tour

  • Node management — connect many Lavalink nodes; automatic selection via PenaltyStrategy (default) or round-robin / least-players / least-load / your own. Region-aware placement from Discord voice endpoints.
  • Session resuming — configured on connect, verified on reconnect; buffered events replay automatically. Fresh sessions transparently rebuild remote players (voice + track + position).
  • Players — full lifecycle with auto-advance, repeat modes, autoplay, pause/seek/volume, live node migration (player.setNode('eu-2')), and deterministic force-cleanup on destroy.
  • Queues — rich operations, bounded history, JSON serialization, pluggable QueueStore adapters, and UnresolvedTracks that resolve right before they play (perfect for restart persistence).
  • Filters — every Lavalink v4 filter with validation and defaults, one-round-trip applies, plus nightcore(), vaporwave(), bassboost(), eightD(), karaoke() presets. See filters.
  • Search — sources & plugin prefixes (LavaSrc's spsearch: etc. work out of the box), requester attachment, playlist flattening, and optional parallel fan-out across all nodes.
  • Events — 20+ typed events at client, node and player level. See the event reference.
  • Errors — a small, documented hierarchy with stable codes and Lavalink's structured error bodies attached. See errors.

Installation

npm install junie
# you also need a Lavalink v4 server:
docker run -d -p 2333:2333 ghcr.io/lavalink-devs/lavalink:4 --port 2333 --password youshallnotpass

Junie targets Node.js ≥ 18.17, ships CommonJS and ESM builds, and has exactly one runtime dependency (ws) — native fetch handles the rest.

Quick start

Full walkthrough (including a Lavalink config file): docs/getting-started.md

  1. Create the client and connect nodes when your bot is ready:
const junie = new Junie({
  nodes: [
    { id: 'eu-1', host: 'eu.example.com', authorization: 'secret', regions: ['europe'] },
    { id: 'us-1', host: 'us.example.com', authorization: 'secret', regions: ['north-america'] },
  ],
  sendToShard: (guildId, payload) => shardFor(guildId).send(payload),
});

client.once('ready', () => junie.init(client.user.id));
  1. Forward raw Discord voice packets (discord.js shown; any library that exposes raw dispatches works):
client.on('raw', (packet) => junie.sendRawData(packet));
  1. Play music:
const player = junie.createPlayer({ guildId, voiceChannelId, textChannelId });
await player.connect();

const { tracks } = await junie.search('never gonna give you up', interaction.user);
player.queue.add(tracks);
await player.play();
  1. React to the queue:
junie.on('trackStart', (player, track) => {
  const channel = client.channels.cache.get(player.textChannelId!);
  channel?.send(`🎶 Now playing: **${track.title}** (${track.requester})`);
});

junie.on('queueEnd', (player) => {
  player.setAutoplay(true); // or destroy, or announce
});

API tour

The clientJunie
junie.init(userId)                    // connect all nodes (call when your bot is ready)
junie.search(query, requester)        // search; URLs, 'ytsearch:' etc. pass through
junie.createPlayer(options)           // create (or fetch) a guild player
junie.getPlayer(guildId)              // undefined-safe fetch
junie.destroyPlayer(guildId)          // graceful teardown
junie.sendRawData(packet)             // feed VOICE_* gateway packets
junie.destroy()                       // players + nodes + listeners, in order
junie.utils.buildTrack(apiTrack)      // rebuild a Track (e.g. from your database)
junie.logger.child('MyBot')           // structured, leveled logging
The playerPlayer
await player.connect()                // join voice; resolves when Lavalink is wired up
await player.play(track?)             // play a track or the next queued one
await player.play('some query')       // strings resolve lazily via search
await player.pause() / .resume()
await player.seek(90_000)             // throws on live streams — by design
await player.setVolume(150)           // 0–1000, clamped
await player.skip(2)                  // skip N tracks
await player.stop()                   // stop (advances); stop(false) keeps the track replayable
await player.setNode('us-1')          // migrate a live player between nodes
await player.destroy()                // zombie-proof, timeout-bounded teardown
player.queue / player.filters         // see below
player.playing / paused / connected / position / ping / volume
player.setTextChannel(id) / setRepeatMode('queue') / setAutoplay(true)
The queueplayer.queue
queue.add(trackOrTracks, position?)   // Track | UnresolvedTrack | raw Lavalink tracks
queue.remove(3) / queue.removeRange(0, 5)
queue.shuffle(seed?) / queue.reverse() / queue.move(4, 0)
queue.clear() / queue.clearHistory()
queue.size / totalSize / duration / isEmpty
queue.repeatMode = 'track'            // 'off' | 'track' | 'queue'
queue.previous / queue.lastTrack      // bounded, restart-friendly history
await queue.restore()                 // hydrate from a QueueStore
Filtersplayer.filters
await player.filters
  .setVolume(1.2)
  .setTimescale({ speed: 1.2, pitch: 1.05 })
  .bassboost(0.7)
  .apply();                           // ONE PATCH — one round trip

player.filters.nightcore();           // ...vaporwave(), karaoke(), eightD()
await player.filters.clear();         // reset + apply
await player.setFilters({ volume: 2 }); // raw merge-and-apply
Nodes & statsjunie.nodes
junie.nodes.best({ voiceEndpoint })   // strategy selection (region-aware)
junie.nodes.get('eu-1')               // Node: .stats, .sessionId, .resumed, .penalty()
await node.getInfo()                  // version + installed plugins
await node.getPluginNames()
await junie.nodes.fanOutSearch('q')   // parallel search across all nodes
node.on('stats', (node, stats) => dashboard.update(node.id, stats));

Architecture at a glance

            ┌───────────────┐  op 4 (voice join)   ┌──────────────────┐
            │ Discord       │ ───────────────────► │ Your bot / shards │
            │ Gateway       │ ◄────VOICE_STATE──── │ (any library)    │
            │               │      _UPDATE,        └────────┬─────────┘
            │               │      VOICE_SERVER_            │ raw packets
            │               │      UPDATE                   ▼
            └───────▲───────┘                       ┌──────────────────┐
                    │ WebRTC audio (Opus)           │      Junie       │
                    │                                │ ┌──────────────┐ │
            ┌───────┴───────┐        REST /v4       │ │ NodeManager  │ │  WSS /v4/websocket
            │   Lavalink    │ ◄──────────────────── │ │  ├ Node …    │ │ ─────────────────►
            │   node(s)     │ ─────────events────►  │ │ PlayerManager│ │
            └───────────────┘  playerUpdate,        │ │  └ Player(s) │ │
                              track events, stats   │ │   Queues     │ │
                                                    │ └──────────────┘ │
                                                    └──────────────────┘

Deep dive: docs/architecture.md — the dual control loop, event routing, state synchronization and the failure modes Junie defends against.

Documentation

Guide Contents
Getting started Installing, Lavalink config, wiring, first commands
Architecture Components, protocol flows, resilience design
Players Player lifecycle & complete method reference
Queue & autoplay Queue operations, persistence, autoplay
Filters Every filter, every range, presets, plugin filters
Nodes & load balancing Strategies, the penalty formula, resuming, failover
Events Full typed event reference
REST & plugins REST API, plugin endpoints (LavaLyrics, LavaSrc, …)
Errors Error hierarchy, codes, recovery guidance
Troubleshooting Common pitfalls and their fixes

Runnable examples: discord.js music bot · custom-shard bot

Testing

npm test        # 111 unit & behavioural tests — no Lavalink server required
npm run build   # dual CJS + ESM output

The suite covers queue semantics, filter validation, penalty math, REST retry/timeout/404 behaviour, WebSocket reconnection with backoff, session-loss rebuilding, voice self-healing, autoplay, node migration and zombie-proof destruction.

Project principles

  1. Correctness under failure first. Every network path has a timeout, every teardown has a finally, every reconnect has a backoff with jitter.
  2. Small, honest API. No magic defaults that surprise you; no methods that quietly hit the network twice.
  3. Types are documentation. The requester generic flows through the entire library, and listening to the wrong event shape is a compile error.
  4. Zero lock-in. Library-agnostic voice wiring, pluggable transports, stores, strategies, resolvers.

License

MIT — © Junie Labs

About

Junie — a production-grade, developer-first Lavalink v4 client for Node.js and TypeScript.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages