/
/
1"""
2go-librespot backend for the Spotify Connect provider.
3
4go-librespot is driven entirely over its local HTTP+WebSocket API: the WebSocket
5``/events`` stream feeds player/session/metadata/volume state into Music Assistant,
6and transport + volume commands are issued via the REST endpoints. Playback control
7works without a configured Spotify *music* provider or the Spotify Web API.
8"""
9
10from __future__ import annotations
11
12import asyncio
13import json
14import os
15from contextlib import suppress
16from functools import partial
17from pathlib import Path
18from typing import TYPE_CHECKING, Any
19
20from music_assistant_models.enums import ContentType, StreamType
21from music_assistant_models.media_items import AudioFormat
22
23from music_assistant.helpers.process import AsyncProcess
24from music_assistant.helpers.util import (
25 interface_name_for_ip,
26 is_port_in_use,
27 select_free_port,
28)
29from music_assistant.providers.spotify_connect.base import (
30 AUDIO_QUALITY_LOSSLESS,
31 LOSSY_BIT_RATES,
32 MAX_LOSSY_BIT_RATE,
33 SpotifyConnectBackend,
34 spotify_source_audio_format,
35)
36from music_assistant.providers.spotify_connect.helpers import (
37 generate_device_id,
38 get_go_librespot_binary,
39)
40from music_assistant.providers.spotify_connect.models import (
41 BackendEvent,
42 BackendEventType,
43 BackendStreamSource,
44 BackendTrackMetadata,
45)
46
47from .client import GoLibrespotClient
48
49if TYPE_CHECKING:
50 import logging
51
52 from music_assistant.mass import MusicAssistant
53 from music_assistant.providers.spotify_connect.models import (
54 AudioChunkReader,
55 BackendEventCallback,
56 )
57
58# go-librespot volume scale; we pin volume_steps to this so the daemon's 0..max
59# volume maps 1:1 to a 0-100 percentage.
60VOLUME_STEPS = 100
61
62# Read size for pulling PCM off the daemon's stdout.
63STREAM_READ_CHUNK = 16384
64
65# Port range the go-librespot API server binds to (loopback only, one per instance).
66API_PORT_RANGE_START = 38800
67API_PORT_RANGE_END = 38900
68
69
70class GoLibrespotBackend(SpotifyConnectBackend):
71 """Spotify Connect backend wrapping a supervised go-librespot daemon."""
72
73 def __init__(
74 self,
75 mass: MusicAssistant,
76 *,
77 instance_id: str,
78 publish_name: str,
79 name: str,
80 logger: logging.Logger,
81 event_callback: BackendEventCallback,
82 crossfade_ms: int = 0,
83 loudness_normalization: bool = True,
84 audio_quality: str = AUDIO_QUALITY_LOSSLESS,
85 ) -> None:
86 """
87 Initialize the backend (cheap; the daemon is launched in ``start``).
88
89 :param mass: The MusicAssistant instance.
90 :param instance_id: The owning provider's instance id; keys the
91 credential/cache dir and the stable Spotify device id.
92 :param publish_name: Device name advertised to the Spotify app.
93 :param name: Display name of the owning provider instance (log messages).
94 :param logger: Logger to use for diagnostics.
95 :param event_callback: Awaited with a normalized BackendEvent for every
96 state change the daemon reports.
97 :param crossfade_ms: Crossfade duration between tracks in milliseconds
98 (0 disables crossfade).
99 :param loudness_normalization: Whether Spotify's loudness normalization
100 should be applied to the audio.
101 :param audio_quality: Ceiling for the streaming quality Spotify is asked
102 to deliver (one of the AUDIO_QUALITY_* tiers).
103 """
104 self.mass = mass
105 self.logger = logger
106 self.name = name
107 self._instance_id = instance_id
108 self._publish_name = publish_name
109 self._event_callback = event_callback
110 self._crossfade_ms = crossfade_ms
111 self._loudness_normalization = loudness_normalization
112 self._audio_quality = audio_quality
113 self.cache_dir = os.path.join(self.mass.cache_path, instance_id)
114 self._binary: str | None = None
115 self._api_port: int = 0
116 self._client: GoLibrespotClient | None = None
117 self._stop_called: bool = False
118 self._daemon_task: asyncio.Task[None] | None = None
119 self._events_task: asyncio.Task[None] | None = None
120 self._proc: AsyncProcess | None = None
121 self._restart_error_count = 0
122 # _audio_format is the original Spotify source codec (Ogg Vorbis at the
123 # configured tier's bitrate), advertised to clients for display.
124 # _decoded_audio_format is the raw PCM go-librespot actually writes to its
125 # stdout after decoding â what the audio reader yields and what the streams
126 # controller hands ffmpeg as the input format. We always emit the source's
127 # own format here; MA is responsible for converting it to whatever each
128 # player needs.
129 self._audio_format = spotify_source_audio_format(audio_quality, lossless=False)
130 self._decoded_audio_format = AudioFormat(
131 content_type=ContentType.PCM_S16LE,
132 codec_type=ContentType.PCM_S16LE,
133 sample_rate=44100,
134 bit_depth=16,
135 channels=2,
136 )
137
138 @property
139 def audio_format(self) -> AudioFormat:
140 """Return the source audio format (advertised to clients for display)."""
141 return self._audio_format
142
143 @property
144 def decoded_audio_format(self) -> AudioFormat:
145 """Return the decoded PCM format the audio reader actually delivers."""
146 return self._decoded_audio_format
147
148 async def start(self) -> None:
149 """Start the backend and its supervised go-librespot daemon."""
150 self._binary = get_go_librespot_binary()
151 self._api_port = await select_free_port(
152 API_PORT_RANGE_START, API_PORT_RANGE_END, host="127.0.0.1"
153 )
154 self._client = GoLibrespotClient(
155 self.mass, f"http://127.0.0.1:{self._api_port}", self.logger
156 )
157 # Two self-healing supervisors: one keeps the daemon process alive, the
158 # other keeps the events websocket connected (reconnecting across daemon
159 # restarts). The events runner resets the daemon's restart backoff once
160 # the websocket is healthy again.
161 self._daemon_task = self.mass.create_task(self._daemon_runner())
162 self._events_task = self.mass.create_task(self._events_runner())
163
164 async def stop(self) -> None:
165 """Stop the daemon and all supervisor tasks."""
166 self._stop_called = True
167 for task in (self._events_task, self._daemon_task):
168 if task and not task.done():
169 task.cancel()
170 with suppress(asyncio.CancelledError):
171 await task
172
173 async def get_stream_source(self) -> BackendStreamSource:
174 """Return the CUSTOM stream source, consumed through the audio reader."""
175 # CUSTOM: the core pulls PCM through get_audio_reader. `-fflags nobuffer`
176 # keeps ffmpeg's own input buffering low so the controller's realtime
177 # pacer owns the (small, bounded) read-ahead.
178 return BackendStreamSource(
179 stream_type=StreamType.CUSTOM,
180 extra_input_args=["-fflags", "nobuffer"],
181 )
182
183 def get_audio_reader(self) -> AudioChunkReader | None:
184 """
185 Return a PCM chunk reader bound to the currently running daemon.
186
187 The reader stays bound to this daemon process: once it exits, the
188 reader returns b"" (clean EOF) even if a restarted daemon is already
189 up. None is returned when no daemon is running.
190 """
191 if (proc := self._proc) is None:
192 return None
193 return partial(proc.read, STREAM_READ_CHUNK)
194
195 async def play(self, uri: str, *, skip_to_uri: str | None = None) -> None:
196 """
197 Start playing a Spotify URI/context, making this device the active one.
198
199 :param uri: Spotify URI (track, album, playlist, ...) â typically a context.
200 :param skip_to_uri: Optional track URI within the context to start at.
201 """
202 assert self._client is not None
203 await self._client.play(uri, skip_to_uri=skip_to_uri)
204
205 async def resume(self) -> None:
206 """Resume playback on the active session."""
207 assert self._client is not None
208 await self._client.resume()
209
210 async def pause(self) -> None:
211 """Pause playback on the active session."""
212 assert self._client is not None
213 await self._client.pause()
214
215 async def deactivate(self) -> None:
216 """Release this device as the active Spotify Connect device."""
217 assert self._client is not None
218 await self._client.stop()
219
220 async def next(self) -> None:
221 """Skip to the next track."""
222 assert self._client is not None
223 await self._client.next()
224
225 async def previous(self) -> None:
226 """Skip to the previous track (or rewind the current one)."""
227 assert self._client is not None
228 await self._client.prev()
229
230 async def seek(self, position_ms: int) -> None:
231 """
232 Seek to an absolute position in the current track.
233
234 :param position_ms: Target position in milliseconds.
235 """
236 assert self._client is not None
237 await self._client.seek(position_ms)
238
239 async def set_volume(self, volume: int) -> None:
240 """
241 Set the daemon's playback volume.
242
243 :param volume: Absolute volume as a 0-100 percentage (translated to
244 go-librespot's own volume scale).
245 """
246 assert self._client is not None
247 await self._client.set_volume(round(volume / 100 * VOLUME_STEPS))
248
249 def _write_config(self, source_ip: str | None) -> None:
250 """
251 Write the go-librespot ``config.yml`` for this instance.
252
253 go-librespot reads a YAML config; JSON is valid YAML, so we emit JSON to
254 sidestep an extra dependency and any string-quoting pitfalls (the device
255 name is user-provided). The config dir doubles as the credential/device
256 cache so the Spotify Connect device stays paired across restarts.
257
258 :param source_ip: Local address of the player-facing interface, or None to
259 advertise the Spotify Connect device on all interfaces.
260 """
261 Path(self.cache_dir).mkdir(parents=True, exist_ok=True)
262 config: dict[str, Any] = {
263 "device_name": self._publish_name,
264 "device_type": "speaker",
265 "device_id": generate_device_id(self._instance_id),
266 "bitrate": LOSSY_BIT_RATES.get(self._audio_quality, MAX_LOSSY_BIT_RATE),
267 "audio_backend": "pipe",
268 # write decoded PCM to the daemon's stdout, which we capture and
269 # forward (the process pipe is always attached, so the daemon's
270 # non-blocking pipe open never fails for lack of a reader). s16le is
271 # the Spotify source representation; MA converts it per player.
272 "audio_output_pipe": "/dev/stdout",
273 "audio_output_pipe_format": "s16le",
274 # external_volume: don't let go-librespot attenuate the PCM â MA / the
275 # target player owns the actual volume. We still receive 'volume'
276 # events and push volume back so the Spotify app slider stays in sync.
277 # No initial_volume: go-librespot ignores it with external_volume set;
278 # the provider pushes the player's volume instead.
279 "external_volume": True,
280 "volume_steps": VOLUME_STEPS,
281 # normalisation is applied by go-librespot itself (-14 LUFS target);
282 # crossfade_duration is in milliseconds, 0 disables it. The crossfade
283 # key needs go-librespot >= 0.8.0; older daemons ignore unknown keys.
284 "normalisation_disabled": not self._loudness_normalization,
285 "crossfade_duration": self._crossfade_ms,
286 "zeroconf_enabled": True,
287 "credentials": {"type": "zeroconf", "zeroconf": {"persist_credentials": True}},
288 "server": {"enabled": True, "address": "127.0.0.1", "port": self._api_port},
289 }
290 # Advertise the Spotify Connect device only on the interface the streams
291 # server binds to, so it lands on the right network on multi-homed hosts.
292 # go-librespot selects advertise interfaces by name, so map the IP to one.
293 if source_ip:
294 if iface_name := interface_name_for_ip(source_ip):
295 config["zeroconf_interfaces_to_advertise"] = [iface_name]
296 else:
297 self.logger.debug(
298 "No interface found for stream bind IP %s; advertising on all interfaces",
299 source_ip,
300 )
301 config_file = os.path.join(self.cache_dir, "config.yml")
302 with open(config_file, "w", encoding="utf-8") as fileobj:
303 json.dump(config, fileobj, indent=2)
304
305 async def _daemon_runner(self) -> None:
306 """Run and supervise the go-librespot daemon, restarting it if it exits."""
307 assert self._binary
308 assert self._client
309 # Loop forever; stop() cancels this task and the explicit stop-check below
310 # handles a graceful exit without a restart.
311 while True:
312 # If the API port was taken while the daemon was down, move to a
313 # fresh port instead of crash-looping on a bind error.
314 if await is_port_in_use(self._api_port, host="127.0.0.1"):
315 self._api_port = await select_free_port(
316 API_PORT_RANGE_START, API_PORT_RANGE_END, host="127.0.0.1"
317 )
318 self._client.base_url = f"http://127.0.0.1:{self._api_port}"
319 self.logger.warning(
320 "API port in use by another process; switching to port %s", self._api_port
321 )
322 self._write_config(await self.mass.streams.get_source_ip())
323 proc: AsyncProcess | None = None
324 try:
325 # stdout carries the decoded PCM (audio_output_pipe=/dev/stdout) and
326 # is consumed through get_audio_reader; stderr carries the daemon's
327 # logs, read here. Because the process pipe owns stdout from spawn
328 # there is always a reader, so go-librespot's non-blocking pipe open
329 # never fails for lack of a consumer.
330 self._proc = proc = AsyncProcess(
331 [self._binary, "--config_dir", self.cache_dir],
332 stdout=True,
333 stderr=True,
334 name=f"go-librespot[{self.name}]",
335 )
336 await proc.start()
337 self.logger.info("Started Spotify Connect background daemon [%s]", self.name)
338 async for line in proc.iter_stderr():
339 self.logger.debug("[%s] %s", self.name, line)
340 except asyncio.CancelledError:
341 raise
342 except Exception as err:
343 self.logger.warning("go-librespot daemon error [%s]: %s", self.name, err)
344 finally:
345 if proc:
346 await proc.close()
347 # The daemon â and thus the Spotify session â is gone. Tell the
348 # provider so a dead/restarting daemon isn't treated as active and
349 # controllable; a fresh 'active' event re-establishes it on reconnect.
350 self._proc = None
351 try:
352 await self._event_callback(BackendEvent(BackendEventType.CONNECTION_LOST))
353 except Exception:
354 # never let a callback error replace a propagating
355 # cancellation or kill the daemon supervisor
356 self.logger.exception("Error while handling daemon exit")
357 if self._stop_called:
358 break
359 self.logger.info("Spotify Connect background daemon stopped for %s", self.name)
360 self._restart_error_count += 1
361 if self._restart_error_count >= 5:
362 await self._event_callback(
363 BackendEvent(
364 BackendEventType.FATAL_ERROR,
365 error="go-librespot daemon failed to start multiple times.",
366 )
367 )
368 return
369 await asyncio.sleep(2)
370
371 async def _events_runner(self) -> None:
372 """Keep the go-librespot events websocket connected, reconnecting as needed."""
373 assert self._client is not None
374 while not self._stop_called:
375 try:
376 if not await self._client.wait_until_ready():
377 await asyncio.sleep(2)
378 continue
379 # A live websocket means the daemon is healthy: reset the restart
380 # backoff counter the daemon supervisor uses.
381 self._restart_error_count = 0
382 await self._client.listen_events(self._handle_event)
383 except asyncio.CancelledError:
384 raise
385 except Exception as err:
386 self.logger.debug("go-librespot events websocket dropped: %s", err)
387 if not self._stop_called:
388 await asyncio.sleep(2)
389
390 async def _handle_event(self, event_type: str, data: dict[str, Any]) -> None:
391 """Translate a single go-librespot websocket event and emit it normalized."""
392 self.logger.debug("Received event [%s]: %s %s", self.name, event_type, data)
393 await self._event_callback(self._translate_event(event_type, data))
394
395 def _translate_event(self, event_type: str, data: dict[str, Any]) -> BackendEvent:
396 """Map a raw go-librespot event onto the normalized BackendEvent model."""
397 # Every event carries the latest context/track uris when present, so the
398 # provider can take playback back when the user moves the active device
399 # away in the Spotify app.
400 context_uri: str | None = data.get("context_uri") or None
401 track_uri: str | None = data.get("uri") or None
402 if event_type == "active":
403 return BackendEvent(
404 BackendEventType.SESSION_ACTIVE, context_uri=context_uri, track_uri=track_uri
405 )
406 if event_type == "inactive":
407 return BackendEvent(
408 BackendEventType.SESSION_INACTIVE, context_uri=context_uri, track_uri=track_uri
409 )
410 if event_type == "playing":
411 return BackendEvent(
412 BackendEventType.PLAYING, context_uri=context_uri, track_uri=track_uri
413 )
414 if event_type == "paused":
415 return BackendEvent(
416 BackendEventType.PAUSED, context_uri=context_uri, track_uri=track_uri
417 )
418 if event_type == "stopped":
419 return BackendEvent(
420 BackendEventType.STOPPED, context_uri=context_uri, track_uri=track_uri
421 )
422 if event_type == "metadata":
423 artists = data.get("artist_names") or []
424 duration_ms = data.get("duration")
425 return BackendEvent(
426 BackendEventType.METADATA,
427 context_uri=context_uri,
428 track_uri=track_uri,
429 metadata=BackendTrackMetadata(
430 track_uri=data.get("uri"),
431 title=data.get("name") or None,
432 artist=artists[0] if artists else None,
433 album=data.get("album_name"),
434 image_url=data.get("album_cover_url"),
435 duration=duration_ms // 1000 if duration_ms else None,
436 position=int(data.get("position", 0)) // 1000,
437 ),
438 )
439 if event_type == "seek":
440 return BackendEvent(
441 BackendEventType.POSITION,
442 context_uri=context_uri,
443 track_uri=track_uri,
444 position=int(data.get("position", 0)) // 1000,
445 )
446 if event_type == "volume" and data.get("value") is not None:
447 # translate the daemon's 0..max scale to a 0-100 percentage
448 max_value = data.get("max") or VOLUME_STEPS
449 return BackendEvent(
450 BackendEventType.VOLUME,
451 context_uri=context_uri,
452 track_uri=track_uri,
453 volume=int(int(data["value"]) / int(max_value) * 100),
454 )
455 return BackendEvent(BackendEventType.OTHER, context_uri=context_uri, track_uri=track_uri)
456