I was working on two extensions built on the official Roon API (Roon: Dialed Up, a Stream Deck plugin, and Roon: Toasted, a Windows companion app) and hit the same wall in both: there’s no way to add a track to the library or heart it. While looking for a workaround I found Arthur Soares’ reverse engineering of the desktop client’s local protocol (code on GitHub). With Claude’s help I dug into it, used it against my own Core, and ended up building a small unofficial library that does just those things, so other extension developers can get the benefit. It’s a stripped-down port of Arthur’s work, limited to the parts needed for this, plus a few additions I needed along the way (heart state for streaming tracks, keepalive, live updates, album-level hearts).
Brian Luczkiewicz, Roon Labs founder, said in that same thread:
We don’t mind people tinkering with their own systems, and reverse engineering for the purpose of interoperability is generally not restricted legally unless encryption was broken, which is not the case here, so we are not going to do anything to interfere with this project.
He was just as clear about the other side of it: the protocol is meant to be internal, Roon changes it freely, and misuse of it can easily cause memory or compute leaks in the Core. I’m taking that warning seriously, and I want it to be the first thing you read about this library, so it comes right below.
Please read this before you use it
Roon does not support this protocol, and Brian was blunt about why:
This protocol is engineered as an internal interface to be used by trusted code, and is not hardened for 3rd party use.
That applies to this library too. In plain terms:
- Any Roon update can break it, without warning. It was tested on Roon 2.73 and nothing else. Don’t build anything on it that you can’t live without.
- Used carelessly, it can hurt your Core. Every connection is a full session on your Core, and the Core holds the objects it sends you until the session ends. I can’t promise this library covers every case Brian had in mind. Don’t leave it running unattended against a Core you can’t afford to restart, and keep an eye on your Core’s memory the first time you use it.
- It is an optional extra, not a foundation. Make it opt-in or give users an off switch, and when it fails, hide the controls instead of retrying. Roon: Toasted has a switch for it, and the Stream Deck link only runs while a heart key is on a deck.
- Keep it on your LAN. There’s no authentication on this protocol, so never expose port 9332 beyond your own network.
What I did to limit the risk:
- A deliberately small surface. It reads what a zone is playing, hearts and un-hearts, and adds to the library. It can’t ban, remove or delete anything, and nothing else is exposed.
- No blind retries. Changes wait for the Core to confirm them. The library doesn’t retry calls on its own, and when the Core turns it down it throws a specific error (
UnsupportedCoreError) so your app can stop and hide the feature. - Sessions that don’t pile up. One connection per program. The Core drops silent sessions after about 30 seconds, so the library pings every 10 seconds instead of reconnecting over and over. The docs tell you to close and reconnect once a day, or when
objectCountgets large, so the Core can let go of what it sent. Both Roon: Toasted and the Stream Deck plugin do exactly that. - Backing off. The library never reconnects by itself, so that is left to the app. Both of my apps retry a lost library connection after 5 s, 15 s, 60 s, then every 5 minutes, and they never hammer the Core.
None of this makes it official or safe the way Roon’s own API is. If you build on it, you’re accepting the same risk I am. Now that the big warning has had all the space it deserves, here are the technical details.
Technical Details
roon-library-controls is a Node.js library that does exactly four things over that protocol:
- Reports what each zone is playing, with its heart and in-library state, live. Hearts set in Roon’s own apps show up too.
- Hearts and un-hearts the playing track.
- Adds the playing track to your library, on its own or as its whole album.
- Hearts and un-hearts the album.
On purpose it does not ban, remove or delete anything. It sits next to the official API rather than replacing it: you keep using node-roon-api for discovery, pairing and transport, and hand this library the Core’s address and id, which every extension already has. Zone ids are the same strings.
const { RoonLibraryClient } = require('roon-library-controls');
// host: your Core's address. coreId: the core_id your extension already has once it's paired.
const roon = new RoonLibraryClient({ host: '192.168.0.68', coreId: core.core_id });
roon.on('track', (zoneId, t) => console.log(t?.title, t?.favorite ? 'hearted' : ''));
// inside an async function:
await roon.connect();
await roon.setFavorite(zoneId, true); // zoneId: a zone_id from the extension API; adds to the library first if it has to
As mentioned at the beginning, two of my extensions are running this library: the Add to Library / Heart key in the Roon: Dialed Up Stream Deck plugin, and Roon: Toasted, a Windows companion app (separate post coming for that one).
A couple of smaller things:
- Hearts are per profile; it uses the Core’s active profile.
- It is not affiliated with or endorsed by Roon Labs.
Repo and docs: GitHub - Vulkandr/roon-library-controls · GitHub (MIT, Node 18+, no dependencies, install with npm install github:Vulkandr/roon-library-controls). The API doc covers every option, method, event and error.
One note from building it that might save someone time: hearts live on the library’s own track object, not on the streaming metadata object, so hearting a TIDAL or Qobuz track means adding it to the library first and then fetching the library object with GetTrackLite. The library does all of that for you.
Questions, bugs and pull requests welcome. Thanks to Arthur for the groundwork; this wouldn’t exist without it.