Skip to content

go-librespot — Full Configuration on Oracle Linux 9

Context

A headless media server (Oracle Linux 9, root) is migrating from librespot-java (no longer actively developed) to go-librespot. The biggest architectural difference: librespot-java had built-in shell hooks for controlling the Bluetooth speaker; go-librespot doesn’t have any — this has to be built manually via its WebSocket API + D-Bus.

Packages

bluez is the Bluetooth daemon, pipewire-pulseaudio provides the PulseAudio compatibility layer (needed because go-librespot talks to PulseAudio, not natively to PipeWire), python3-dbus and websockets support our own bridge script.

dnf install -y bluez pipewire-pulseaudio python3-dbus
pip install websockets --break-system-packages

Directories and binary

The working directory holds the binary, the audio cache, and helper scripts in one place.

mkdir -p /opt/go-librespot/{bin,cache,scripts}

The release asset filename changes between versions, so instead of guessing, pull the current list from the GitHub API and pick the file for your architecture (linux_amd64 on a typical x86_64 server):

curl -s https://api.github.com/repos/devgianlu/go-librespot/releases/latest | grep browser_download_url
curl -L -o /opt/go-librespot/go-librespot.tar.gz "<url_from_the_list_above>"
tar -xzf /opt/go-librespot/go-librespot.tar.gz -C /opt/go-librespot/bin
chmod +x /opt/go-librespot/bin/go-librespot

Dedicated user

PipeWire on Oracle Linux 9 has a deliberate safeguard (ConditionUser=!root) that blocks the PulseAudio layer from starting as root. Rather than working around that, the whole audio and Spotify Connect stack runs under a service account, spotify.

useradd -r -m -G audio -s /usr/sbin/nologin spotify
loginctl enable-linger spotify
chown -R spotify:spotify /opt/go-librespot

enable-linger makes this account’s systemctl --user services come up automatically at boot, without needing an actual login.

PipeWire — disabling the active-seat restriction

WirePlumber by default only manages Bluetooth for the session logind considers active on a given seat (a desktop safeguard so a login manager doesn’t steal headphones from other users). A headless server has no such session, so without disabling this, WirePlumber never registers Bluetooth support at all.

File: /home/spotify/.config/wireplumber/wireplumber.conf.d/51-disable-seat-monitoring.conf

wireplumber.profiles = {
  main = {
    monitor.bluez.seat-monitoring = disabled
  }
}

After creating the file (as user spotify), start the audio services:

systemctl --user daemon-reload
systemctl --user enable --now pipewire pipewire-pulse wireplumber

Pairing the Bluetooth speaker

pair establishes the actual security relationship in the BR/EDR profile (trust alone isn’t enough for audio to connect); trust allows automatic reconnection without manual intervention.

bluetoothctl
remove 00:12:6F:15:4F:EB
scan on
# wait until the device shows up in the list, then:
scan off
pair 00:12:6F:15:4F:EB
trust 00:12:6F:15:4F:EB
connect 00:12:6F:15:4F:EB

go-librespot configuration

audio_backend_runtime_socket points explicitly to the spotify user’s PipeWire-Pulse socket (964 is that user’s UID — check id -u spotify on your system and substitute). zeroconf_interfaces_to_advertise limits mDNS advertising to the physical network interface.

File: /opt/go-librespot/config.yml

device_name: "go-librespot"
device_type: computer
log_level: debug

audio_backend: pulseaudio
audio_backend_runtime_socket: /run/user/964/pulse/native
audio_device: bluez_output.00_12_6F_15_4F_EB.1
bitrate: 320
volume_steps: 100
initial_volume: 54
normalisation_pregain: 3.0

zeroconf_enabled: true
zeroconf_port: 44667
zeroconf_backend: builtin
zeroconf_interfaces_to_advertise: ["enp0s31f6"]

credentials:
  type: zeroconf
  zeroconf:
    persist_credentials: false

server:
  enabled: true
  address: "0.0.0.0"
  port: 24879

Audio socket guard script

go-librespot starts faster than the spotify user’s PipeWire can create the pulse/native socket. Without this guard, the first playback attempts after every boot would fail silently with “no such file or directory”.

File: /opt/go-librespot/scripts/wait-for-pulse-socket.sh

#!/bin/bash
SOCK=/run/user/964/pulse/native
for i in $(seq 1 30); do
    [ -S "$SOCK" ] && exit 0
    sleep 1
done
echo "Timeout waiting for $SOCK" >&2
exit 1
chmod +x /opt/go-librespot/scripts/wait-for-pulse-socket.sh

Event bridge (replaces librespot-java’s hooks)

Listens to go-librespot’s WebSocket API and reacts to two events: playback_ready/will_play (playback starting) connects the BT speaker via D-Bus instead of shelling out to bluetoothctl as a separate process; stopped (session torn down remotely, typically when the phone goes to sleep in the background) restarts the service, because go-librespot doesn’t recover connectivity on its own in this specific scenario. Disconnecting BT after a plain pause is deliberately not done — the speaker sleeps on its own after 15 minutes of inactivity.

File: /opt/go-librespot/scripts/events-bridge.py

#!/usr/bin/env python3
import asyncio
import json
import subprocess
import syslog
import time

import dbus
import websockets

WS_URL = "ws://127.0.0.1:24879/events"
SPEAKER_MAC = "00:12:6F:15:4F:EB"
RECONNECT_DELAY_S = 5
RESTART_DEBOUNCE_S = 15

syslog.openlog("go-librespot-events", syslog.LOG_PID)

_last_restart = 0.0


def _device_path(mac: str) -> str:
    return f"/org/bluez/hci0/dev_{mac.replace(':', '_')}"


def bt_connect() -> None:
    try:
        bus = dbus.SystemBus()
        obj = bus.get_object("org.bluez", _device_path(SPEAKER_MAC))
        dbus.Interface(obj, "org.bluez.Device1").Connect()
        syslog.syslog(syslog.LOG_INFO, f"Connect OK: {SPEAKER_MAC}")
    except dbus.exceptions.DBusException as e:
        if "AlreadyConnected" in str(e):
            syslog.syslog(syslog.LOG_INFO, f"Already connected: {SPEAKER_MAC}")
        else:
            syslog.syslog(syslog.LOG_ERR, f"Connect FAILED: {e}")


def restart_go_librespot() -> None:
    global _last_restart
    now = time.monotonic()
    if now - _last_restart < RESTART_DEBOUNCE_S:
        syslog.syslog(syslog.LOG_DEBUG, "Restart skipped (debounce)")
        return
    _last_restart = now
    syslog.syslog(syslog.LOG_WARNING, "Session dropped (stopped) — restarting go-librespot")
    result = subprocess.run(
        ["/usr/bin/systemctl", "restart", "go-librespot.service"],
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        syslog.syslog(syslog.LOG_ERR, f"Restart FAILED: {result.stderr.strip()}")


async def bridge() -> None:
    while True:
        try:
            syslog.syslog(syslog.LOG_INFO, f"Connecting to {WS_URL}")
            async with websockets.connect(WS_URL) as ws:
                syslog.syslog(syslog.LOG_INFO, "Connected, listening for events")
                async for raw in ws:
                    try:
                        msg = json.loads(raw)
                    except json.JSONDecodeError:
                        continue

                    event_type = msg.get("type") or msg.get("event") or msg.get("name")

                    if event_type in ("playback_ready", "will_play"):
                        bt_connect()
                    elif event_type == "stopped":
                        restart_go_librespot()
                    else:
                        syslog.syslog(syslog.LOG_DEBUG, f"Ignored event: {event_type}")

        except (websockets.exceptions.ConnectionClosed, ConnectionRefusedError, OSError) as e:
            syslog.syslog(syslog.LOG_WARNING, f"WS dropped ({e}), retrying in {RECONNECT_DELAY_S}s")
            await asyncio.sleep(RECONNECT_DELAY_S)
        except Exception as e:
            syslog.syslog(syslog.LOG_ERR, f"Unexpected error: {e}")
            await asyncio.sleep(RECONNECT_DELAY_S)


if __name__ == "__main__":
    asyncio.run(bridge())
chmod +x /opt/go-librespot/scripts/events-bridge.py

Polkit rule

The bridge runs as user spotify, but without an active graphical session systemctl restart normally demands interactive authentication that nobody is there to provide. This rule grants that user permission to restart exactly this one service — not blanket admin rights.

File: /etc/polkit-1/rules.d/49-go-librespot-restart.rules

polkit.addRule(function(action, subject) {
    if (action.id == "org.freedesktop.systemd1.manage-units" &&
        action.lookup("unit") == "go-librespot.service" &&
        subject.user == "spotify") {
        return polkit.Result.YES;
    }
});
systemctl restart polkit

Main service

After/Requires on go-librespot-events.service guarantees the event bridge is ready before the daemon starts sending the events it’s meant to receive.

File: /etc/systemd/system/go-librespot.service

[Unit]
Description=go-librespot Spotify Connect daemon
After=network-online.target sound.target go-librespot-events.service
Wants=network-online.target
Requires=go-librespot-events.service

[Service]
Type=simple
User=spotify
ExecStartPre=/opt/go-librespot/scripts/wait-for-pulse-socket.sh
WorkingDirectory=/opt/go-librespot
ExecStart=/opt/go-librespot/bin/go-librespot --config_dir /opt/go-librespot
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Event bridge service

File: /etc/systemd/system/go-librespot-events.service

[Unit]
Description=go-librespot events bridge (BT connect + restart on session drop)

[Service]
Type=simple
User=spotify
ExecStart=/usr/bin/python3 /opt/go-librespot/scripts/events-bridge.py
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Activation

Order matters — the event bridge first, so it’s ready for the WebSocket by the time the main daemon starts.

systemctl daemon-reload
systemctl enable --now go-librespot-events.service
systemctl enable --now go-librespot.service

Known trade-offs of this setup

  • The first startup after a full reboot is sometimes invisible in Spotify (suspected cause: switch-side IGMP snooping) — observed, no confirmed fix.
  • audio_backend_runtime_socket, the path in wait-for-pulse-socket.sh, and zeroconf_interfaces_to_advertise all hardcode the UID (964) and interface name (enp0s31f6) — update these if either ever changes.
  • No bluealsa package is available for Oracle Linux 9 (no RPM), which is why PipeWire was chosen over the simpler ALSA-only path used in the old librespot-java setup — the entire user/linger/polkit complexity is a direct consequence of that choice.
Last updated on