jukebox.

Plugins

A plugin is an ordinary Node module that the server loads into its own process. That sentence contains both the capability and the limit, so it is worth reading twice before writing one.

The bargain

A plugin can do anything the server can do. It runs with the server’s process, its file access and its network. The permissions in a manifest are shown to whoever installs it and are not enforced by anything.

This is a deliberate choice with a known cost. Home Assistant and Volumio both landed here after trying harder, because the plugins people actually want are wrappers around HTTP, MQTT and hardware — and every real sandbox either blocks those or becomes a process boundary with its own IPC to maintain. The architecture sets out when that changes: the day the store opens to unknown authors, the families that only need fetch get sandboxed and sources stay in reviewed Node.

What the host does guarantee:

The manifest

{
  "id": "listenbrainz",
  "name": "ListenBrainz",
  "version": "1.0.0",
  "hostApi": "^1.0.0",          // range against the host, refused if it does not match
  "main": "index.mjs",
  "description": "Sends your listening history to ListenBrainz.",
  "author": "jukebox",
  "permissions": ["network:api.listenbrainz.org", "events:play"],
  "contributes": { /* where it appears in the UI — see below */ }
}

What the host hands you

export function activate(host) { /* … */ }
export function deactivate() { /* … */ }
host.log(…) Prefixed with the plugin id, so a noisy plugin is identifiable
host.config / host.setConfig(next) The plugin’s own settings, as stored by the server
host.registerJob(name, handler) A job kind, namespaced to the plugin so two cannot collide
host.registerCommand(name, handler) Something the user can invoke — see Reaching the user
host.createPlaylist(name, trackIds) So a command can return a playlist it just built
host.net Sockets, requests and timers the host closes when the plugin stops
host.on('play', handler) Server events; returns an unsubscribe the host also calls for you

host.net deserves the emphasis. A plugin can reach fetch and net.connect directly — it is in the same process. Using the host’s transports instead is what makes disabling the plugin actually stop its traffic, and it is the difference between a plugin you can turn off and one you have to restart the server to be rid of.

Reaching the user

A plugin never renders anything. It declares where it wants to appear and what the entry says; the host draws it. No plugin code runs in the page, which is why a third-party plugin cannot break the interface, restyle it, or read what is on screen.

contributes.settings

A list of fields the admin page renders as a form:

"settings": [
  { "key": "token", "label": "User token", "type": "password",
    "help": "From listenbrainz.org/profile — the plugin does nothing until this is set." },
  { "key": "url", "label": "Server", "type": "text", "default": "https://api.listenbrainz.org" }
]

Whatever the user saves arrives as host.config.

contributes["track.contextMenu"]

Entries in the right-click menu of a track or a selection:

"track.contextMenu": [
  { "id": "lb.similar", "label": "Find similar tracks", "command": "similar" }
]

Each entry names a command registered with host.registerCommand. The menu shows which plugin an entry came from, and greys out any entry the plugin cannot currently run — a stopped plugin still has a manifest, so contributing and being able to run are different facts and the menu says which is which.

contributes["track.tab"]

A tab in a track’s information window:

"track.tab": [
  { "id": "lyrics.words", "label": "Lyrics", "command": "words" }
]

The command runs when the tab is opened, not when the window is — a plugin that reaches a third party costs nothing to someone who only wanted to fix a track number. It is asked about one track: “the lyrics of these nine songs” is not a question, so the window keeps only its own tabs when several are selected.

Unlike a menu entry, a stopped plugin’s tab is not drawn at all. A greyed entry says “this exists and is switched off”, which is worth saying; an empty tab is worse than a window with one tab fewer.

contributes["home.section"]

A strip on the home page. The host renders it as data — a title and rows — never as markup.

contributes.theme

A whole skin for the app, declared as data:

"theme": {
  "label": "Hot Dog",              // what the picker shows
  "rowHeight": 21,                 // must match --row-h; the virtualiser needs it
  "playlistArt": false,            // generated art next to sidebar playlists
  "tokens": { "--accent": "#c40000", "--content": "#fffef0" /* … */ },
  "css": ""                        // optional, for rules beyond tokens
}

tokens is the whole story for most skins — the app’s components read CSS custom properties and nothing else, so a theme is a block of token redefinitions, and the host writes that block itself. Only keys starting with -- are kept: the tokens door does not accept arbitrary declarations.

css exists for skins that also move furniture. It is injected as-is, so it must scope every rule under [data-theme="plugin-<id>"] — the theme registers under that prefixed id precisely so a plugin can never shadow a built-in skin or another plugin’s.

A theme is styling, not script: everything lands in a <style> element and none of it runs. It follows the plugin’s switch — disable the plugin and the skin leaves the picker; whoever was wearing it falls back to the base skin. The block above is, in full, what a working theme plugin declares — a label and a handful of tokens are enough to put a new skin in the picker.

What a command may answer

host.registerCommand handlers return one of four shapes, and the interface does something different with each:

Result What the app does
{ kind: 'done', message? } Says so in the status line
{ kind: 'job', job } Sends it to the display that cycles through running jobs
{ kind: 'playlist', id, name } Opens the playlist that was just built
{ kind: 'tracks', ids } Selects them and scrolls there — not saved
{ kind: 'text', title?, body } Renders it in the tab that asked, as text

text is plain text and the host renders it as such. A plugin that could answer with markup would be a plugin that can restyle the window and read what is around it, which is the one thing contributes exists to prevent — so what a tab can do to the interface ends at line breaks.

The tracks kind is the other one worth understanding. “Find me more like this” produces a selection, not a playlist: an exploratory command should not leave something behind for the user to delete. Saving it is then their decision rather than the plugin’s.

A command has thirty seconds. A plugin that hangs would otherwise hang the menu with it; after that the app is told command_timeout with the plugin’s name. Failures are separated so the interface can name the right culprit — plugin_disabled, unknown_command, command_failed with the plugin’s own message — and a 500 means the server broke, which sends someone to debug a different thing.

Lifecycle and failure

installed → active when it loads and activates. failed if either throws, with the reason kept and shown in the admin page. disabled when switched off, which stops its listeners, closes its transports and cancels its timers.

A failing plugin is ordinary and the interface treats it that way: it stays listed, with its error, next to a button that enables it again.

Installing

From a store index — a URL, asked for every time and never defaulted, because installing runs someone else’s code as the server and which store to trust is a decision the user makes rather than inherits. Entries that need a newer host say so before the install rather than failing after it.

A worked example

The shipped plugins/listenbrainz is deliberately small and exercises the whole surface: it takes a token through contributes.settings, subscribes with host.on('play'), queues listens through host.net so disabling it stops the traffic, and registers a flush command that returns { kind: 'done', message: '3 listens sent.' }.

plugins/lyrics covers the other half: it contributes a place rather than an action. Thirty lines, one track.tab, one command that answers { kind: 'text' } — and it shows what a lookup plugin should do when the lookup fails. A track LRCLIB has never heard of, an entry that exists but holds no words, and a server that cannot be reached are three different sentences, and none of them throws: a command that throws marks the plugin failed, and a lyrics server being down for a minute is not a broken plugin.