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-packagesDirectories 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-librespotDedicated 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-librespotenable-linger makes this account’s systemctl --user services come up automatically at boot, without needing an actual login.
PipeWire — disabling the active-seat restriction
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 wireplumberPairing 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:EBgo-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: 24879Audio 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 1chmod +x /opt/go-librespot/scripts/wait-for-pulse-socket.shEvent 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.pyPolkit 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 polkitMain 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.targetEvent 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.targetActivation
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.serviceKnown 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 inwait-for-pulse-socket.sh, andzeroconf_interfaces_to_advertiseall hardcode the UID (964) and interface name (enp0s31f6) — update these if either ever changes.- No
bluealsapackage 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.