/
/
1"""
2MA-facing provider logic for the Spotify Connect plugin.
3
4The provider owns everything Music Assistant sees: one Spotify Connect device
5(a backend daemon plus its AudioSource) per connected player, stream details,
6playback claims and volume policy. It is backend-agnostic: all Spotify
7specifics live behind the ``SpotifyConnectBackend`` contract and reach the
8provider as normalized ``BackendEvent``s.
9"""
10
11from __future__ import annotations
12
13import asyncio
14import re
15import time
16from dataclasses import dataclass, field
17from functools import partial
18from typing import TYPE_CHECKING, Final, cast
19
20from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
21from music_assistant_models.enums import (
22 ConfigEntryType,
23 EventType,
24 MediaType,
25 ProviderFeature,
26 RepeatMode,
27 SourceControl,
28)
29from music_assistant_models.errors import AudioError, MediaNotFoundError
30from music_assistant_models.media_items import (
31 AudioSource,
32 ProviderMapping,
33)
34from music_assistant_models.streamdetails import StreamDetails, StreamMetadata
35
36from music_assistant.constants import CONF_CROSSFADE_DURATION
37from music_assistant.helpers.config_entries import (
38 CONF_CONNECTED_PLAYERS,
39 CONF_PUBLISH_NAME_TEMPLATE,
40 create_connected_players_entry,
41 create_publish_name_template_entry,
42 resolve_publish_name,
43)
44from music_assistant.models.plugin import PluginProvider, SourceControlValue
45
46from .base import (
47 AUDIO_QUALITY_LOSSLESS,
48 AUDIO_QUALITY_OPTIONS,
49)
50from .go_librespot import GoLibrespotBackend
51from .helpers import get_go_librespot_binary
52from .models import BackendEventType
53from .soloist import (
54 VOLUME_MODE_PLAYER_ONLY,
55 VOLUME_MODE_SYNC_SPOTIFY,
56 SoloistBackend,
57 SoloistBinaryManager,
58)
59
60if TYPE_CHECKING:
61 from collections.abc import AsyncGenerator, Callable
62
63 from music_assistant_models.config_entries import ProviderConfig
64 from music_assistant_models.event import MassEvent
65 from music_assistant_models.provider import ProviderManifest
66
67 from music_assistant.mass import MusicAssistant
68 from music_assistant.models.player import Player
69
70 from .base import SpotifyConnectBackend
71 from .models import BackendEvent, BackendPlaybackOptions, BackendTrackMetadata
72
73# Backend selection, collected by the setup flow (stored in setup_data).
74CONF_BACKEND = "backend"
75BACKEND_GO_LIBRESPOT = "go_librespot"
76BACKEND_SOLOIST = "soloist"
77
78# Soloist-specific values collected by the setup flow (see CONF_BACKEND).
79CONF_API_KEY = "soloist_api_key"
80CONF_SOLOIST_CONSENT = "soloist_download_consent"
81CONF_VOLUME_MODE = "volume_mode"
82
83# Playback behavior applied by the Spotify engine itself (both backends).
84CONF_LOUDNESS_NORMALIZATION = "loudness_normalization"
85MAX_CROSSFADE_DURATION = 12 # seconds, matching the Spotify apps' slider
86CONF_AUDIO_QUALITY = "audio_quality"
87
88AUDIO_QUALITY_VALUES: Final = {option.value for option in AUDIO_QUALITY_OPTIONS}
89
90# The selectable volume modes (labels resolve from strings.json); a runtime
91# option on the provider's settings page, not part of the setup flow.
92VOLUME_MODE_OPTIONS: Final = [
93 ConfigValueOption(VOLUME_MODE_PLAYER_ONLY),
94 ConfigValueOption(VOLUME_MODE_SYNC_SPOTIFY),
95]
96
97SUPPORTED_FEATURES = {ProviderFeature.AUDIO_SOURCE}
98
99# When playback is paused the backend stops writing PCM. If no PCM arrives for
100# this long while we're not in a 'playing' state, end the stream (clean EOF) so
101# the player leaves the playing state; the next 'playing' event re-streams.
102PAUSE_EOF_TIMEOUT_S = 0.5
103
104# A stop after a pause runs while the player's playback lock is held, so a slow one
105# delays whatever the user does next; warn when it takes longer than this.
106SLOW_STOP_WARN_S = 10.0
107
108# Seconds to wait for the backend to report 'playing' after a resume request.
109PLAYBACK_START_TIMEOUT_S = 3.0
110
111# Debounce before acting on an externally-triggered 'playing' event (see
112# _deferred_play_media_fire for why).
113PLAY_MEDIA_DEBOUNCE_S = 0.5
114
115# Ignore Spotify volume events for this long after a session becomes active, so
116# the player's own volume wins over the backend's initial value on (re)connect.
117INITIAL_VOLUME_GRACE_S = 3.0
118
119# User-facing message for the "not the active Spotify device" failure.
120# {0} is the Spotify Connect device's published name (see _not_active_error).
121NOT_ACTIVE_DEVICE_MESSAGE = (
122 "'{0}' is not the active Spotify playback device. "
123 "Open the Spotify app, select it as the playback device, and try again."
124)
125
126
127@dataclass
128class _PlayerDaemon:
129 """State for one connected player's Spotify Connect daemon."""
130
131 # the connected player this daemon plays on; doubles as the AudioSource item_id
132 player_id: str
133 # player_id sanitized for use in filesystem paths and identity keys
134 safe_player_id: str
135 # the name this daemon advertises as its device name in the Spotify app
136 publish_name: str
137 stream_metadata: StreamMetadata
138 backend: SpotifyConnectBackend = field(init=False)
139 audio_source: AudioSource = field(init=False)
140 stop_called: bool = False
141 # Currently active player (the one currently playing or selected)
142 active_player_id: str | None = None
143 # in_use_by_player is the queue currently streaming us. Claimed in
144 # on_source_selected (NOT in get_stream_details â that path also runs
145 # from queue preload, where claiming would block a later cross-queue
146 # handoff). Released in on_source_unselected when the session id
147 # matches, or in _clear_active_player on the backend's 'inactive' event.
148 in_use_by_player: str | None = None
149 # active_session_id is the controller-provided token for the current
150 # stream request â used to reject stale on_source_unselected callbacks
151 # after a same-queue reconnect supersedes the previous request.
152 active_session_id: str | None = None
153 # tracks the backend's play/pause state from its 'playing' / 'paused' /
154 # 'inactive' events; gates the resume kick in on_source_selected (skip if
155 # already playing) and the play_media trigger in the event handler.
156 playing: bool = False
157 # True while MA is the active Spotify Connect device (set on 'active',
158 # cleared on 'inactive'); gates get_stream_details and transport commands.
159 spotify_session_active: bool = False
160 # holds the single in-flight deferred play_media task scheduled once the
161 # session is both active and playing; cancelled when a later event makes
162 # that state stale.
163 pending_play_media_task: asyncio.Task[None] | None = None
164 # holds the in-flight stop of a paused player (pipe-fed backends
165 # only); the stop dispatches right away, but a 'playing' event cancels
166 # it while it is still in flight (a slow player can hold it for up to
167 # 10s), so a resume is never killed by a stop landing late.
168 pending_pause_stop_task: asyncio.Task[None] | None = None
169 last_session_active_time: float = 0
170 last_volume_sent: int | None = None
171 # Last context/track URIs seen on the event stream. Used to take playback
172 # back (make ourselves the active Spotify device) when the user switched
173 # the active device away in the Spotify app and then presses play in MA.
174 last_context_uri: str | None = None
175 last_track_uri: str | None = None
176 # Latest playback options reported by the backend. Cached on every
177 # OPTIONS_CHANGED â an externally triggered session reports them before
178 # the queue claim exists â and pushed to the queue once claimed in
179 # on_source_selected. Cleared when the session ends.
180 last_playback_options: BackendPlaybackOptions | None = None
181
182
183class SpotifyConnectProvider(PluginProvider):
184 """Implementation of a Spotify Connect Plugin (backed by SpotifyConnectBackends)."""
185
186 reload_on_streams_network_change = True
187
188 def __init__(
189 self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
190 ) -> None:
191 """Initialize MusicProvider."""
192 super().__init__(mass, manifest, config, SUPPORTED_FEATURES)
193 self._daemons: dict[str, _PlayerDaemon] = {}
194 self._reconcile_lock = asyncio.Lock()
195 self._unload_called = False
196 self._unsubscribe: Callable[[], None] | None = None
197 # the connected players are immutable per load: config changes reload the provider
198 self._assigned_player_ids: tuple[str, ...] = tuple(
199 cast("list[str]", self.get_config_value(CONF_CONNECTED_PLAYERS) or [])
200 )
201
202 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
203 """Return runtime options for this provider."""
204 # The backend selection and the soloist secrets are managed by the setup
205 # flow (stored in setup_data) and stay hidden; the volume mode is a
206 # visible runtime option for soloist configs.
207 is_soloist = self.get_setup_value(CONF_BACKEND) == BACKEND_SOLOIST
208 return (
209 create_connected_players_entry(
210 self.mass, cast("list[str]", self.get_config_value(CONF_CONNECTED_PLAYERS) or [])
211 ),
212 create_publish_name_template_entry(self.get_config_value(CONF_PUBLISH_NAME_TEMPLATE)),
213 ConfigEntry(
214 key=CONF_BACKEND,
215 type=ConfigEntryType.STRING,
216 default_value=BACKEND_GO_LIBRESPOT,
217 required=False,
218 hidden=True,
219 ),
220 ConfigEntry(
221 key=CONF_API_KEY,
222 type=ConfigEntryType.SECURE_STRING,
223 required=False,
224 hidden=True,
225 ),
226 ConfigEntry(
227 key=CONF_SOLOIST_CONSENT,
228 type=ConfigEntryType.BOOLEAN,
229 default_value=False,
230 required=False,
231 hidden=True,
232 ),
233 ConfigEntry(
234 key=CONF_VOLUME_MODE,
235 type=ConfigEntryType.STRING,
236 default_value=VOLUME_MODE_PLAYER_ONLY,
237 required=False,
238 options=VOLUME_MODE_OPTIONS,
239 hidden=not is_soloist,
240 ),
241 ConfigEntry(
242 key=CONF_CROSSFADE_DURATION,
243 type=ConfigEntryType.INTEGER,
244 range=(0, MAX_CROSSFADE_DURATION),
245 default_value=0,
246 required=False,
247 ),
248 ConfigEntry(
249 key=CONF_LOUDNESS_NORMALIZATION,
250 type=ConfigEntryType.BOOLEAN,
251 default_value=True,
252 required=False,
253 ),
254 ConfigEntry(
255 key=CONF_AUDIO_QUALITY,
256 type=ConfigEntryType.STRING,
257 default_value=AUDIO_QUALITY_LOSSLESS,
258 required=False,
259 options=AUDIO_QUALITY_OPTIONS,
260 ),
261 )
262
263 async def handle_async_init(self) -> None:
264 """Handle async initialization of the provider."""
265 # Surface a broken engine setup as a load error (like the per-instance
266 # model did through its single backend start): the soloist binary is
267 # installed/verified once here â the per-daemon starts hit the manager's
268 # recently-verified fast path â and go-librespot must be on PATH.
269 if self.get_setup_value(CONF_BACKEND) == BACKEND_SOLOIST:
270 await SoloistBinaryManager(self.mass).ensure_fresh(
271 bool(self.get_setup_value(CONF_SOLOIST_CONSENT))
272 )
273 else:
274 get_go_librespot_binary()
275
276 async def loaded_in_mass(self) -> None:
277 """Start the Connect daemons and follow the connected players' lifecycle."""
278 await super().loaded_in_mass()
279 if self._assigned_player_ids:
280 self._unsubscribe = self.mass.subscribe(
281 self._on_player_event,
282 event_filter=(
283 EventType.PLAYER_ADDED,
284 EventType.PLAYER_REMOVED,
285 EventType.PLAYER_CONFIG_UPDATED,
286 EventType.PLAYER_UPDATED,
287 ),
288 id_filter=self._assigned_player_ids,
289 )
290 # players register after plugins load, so on a cold boot this typically starts
291 # nothing yet: the PLAYER_ADDED events drive the actual daemon startups
292 await self._reconcile()
293
294 async def unload(self, is_removed: bool = False) -> None:
295 """Handle close/cleanup of the provider."""
296 self._unload_called = True
297 if self._unsubscribe is not None:
298 self._unsubscribe()
299 self._unsubscribe = None
300 async with self._reconcile_lock:
301 daemons = list(self._daemons.values())
302 self._daemons.clear()
303 if daemons:
304 await asyncio.gather(*(self._stop_daemon(daemon) for daemon in daemons))
305 # drop the standing source entries from the players' cached source lists
306 for daemon in daemons:
307 self.mass.players.trigger_player_update(daemon.player_id)
308
309 async def get_audio_sources(self) -> list[AudioSource]:
310 """Return the AudioSources this plugin currently exposes."""
311 return [daemon.audio_source for daemon in self._daemons.values()]
312
313 def get_player_audio_sources(self, player_id: str) -> list[AudioSource]:
314 """Return the AudioSource bound to the given connected player, if any."""
315 daemon = self._daemons.get(player_id)
316 return [daemon.audio_source] if daemon else []
317
318 async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
319 """
320 Return StreamDetails for streaming the Spotify Connect audio.
321
322 Side-effect-free: ownership is claimed in on_source_selected (which the
323 streams controller fires before this method on the actual stream
324 request). Keeping this idempotent means preload paths can fetch
325 streamdetails without claiming the source and blocking a cross-queue
326 handoff.
327
328 Raises AudioError when MA is not the active Spotify Connect device and
329 no previous playback context is known to resume â the user then has to
330 start playback from the Spotify app once.
331 """
332 daemon = self._daemons.get(item_id)
333 if daemon is None:
334 raise MediaNotFoundError(f"Unknown AudioSource: {item_id}")
335 # Only refuse when we can neither resume nor take playback back. If a last
336 # context is known we let the stream proceed; on_source_selected then takes
337 # playback back (makes us the active device) before audio is pulled.
338 if not daemon.playing and not daemon.spotify_session_active and not daemon.last_context_uri:
339 raise self._not_active_error(daemon)
340 # The backend describes how its audio is consumed: CUSTOM (the core pulls
341 # PCM from get_audio_stream) or a named pipe read directly by ffmpeg.
342 # decoded_audio_format tells the core the PCM format while audio_format
343 # keeps the source codec for display; MA resamples to each player's
344 # format as needed.
345 # expiration=0: never reuse a cached streamdetails so the active-device
346 # check above re-runs on every play attempt.
347 stream_source = await daemon.backend.get_stream_source()
348 return StreamDetails(
349 provider=self.instance_id,
350 item_id=item_id,
351 audio_format=daemon.backend.audio_format,
352 decoded_audio_format=daemon.backend.decoded_audio_format,
353 media_type=MediaType.AUDIO_SOURCE,
354 stream_type=stream_source.stream_type,
355 path=stream_source.path,
356 stream_metadata=daemon.stream_metadata,
357 extra_input_args=stream_source.extra_input_args,
358 expiration=0,
359 )
360
361 async def get_audio_stream(
362 self,
363 streamdetails: StreamDetails,
364 seek_position: int = 0,
365 ) -> AsyncGenerator[bytes]:
366 """
367 Yield raw PCM from the backend's audio pipe for the live AudioSource.
368
369 Only used for backends with a CUSTOM stream source (NAMED_PIPE backends
370 are read directly by the streams controller). When playback pauses the
371 backend stops writing PCM; we then end the stream (clean EOF) so the
372 consuming player leaves the playing state. The next ``playing`` event
373 re-triggers playback.
374
375 :param streamdetails: The StreamDetails of the AudioSource being streamed.
376 :param seek_position: Ignored â seeking is handled upstream by Spotify,
377 not by replaying the bytestream.
378 """
379 daemon = self._daemons.get(streamdetails.item_id)
380 if daemon is None:
381 raise MediaNotFoundError(f"Unknown AudioSource: {streamdetails.item_id}")
382 read_chunk = daemon.backend.get_audio_reader()
383 if read_chunk is None:
384 raise AudioError("Spotify Connect daemon is not running")
385 # No pacing here: the streams controller's realtime pacer (ffmpeg readrate
386 # with a small initial burst) is the single pacing authority for live
387 # sources. Backpressure through the audio pipe bounds how far the backend
388 # (whose pipe backend is not realtime-paced) runs ahead, while the burst
389 # headroom absorbs scheduling jitter that would otherwise underrun the
390 # player. Pacing a second time here would pin the feed to exactly realtime
391 # and starve that headroom.
392 while True:
393 try:
394 chunk = await asyncio.wait_for(read_chunk(), timeout=PAUSE_EOF_TIMEOUT_S)
395 except TimeoutError:
396 # No PCM for a while. If playback is no longer active (paused /
397 # stopped / session gone) end the stream so the player goes idle;
398 # a brief buffering gap while still playing just keeps waiting.
399 if not daemon.playing:
400 return
401 continue
402 if not chunk:
403 return # audio pipe closed (backend exited / restarting)
404 yield chunk
405
406 def delivers_normalized_audio(self, streamdetails: StreamDetails) -> bool:
407 """
408 Return whether Spotify applies loudness normalization to this source.
409
410 :param streamdetails: Stream details of the active Spotify Connect source.
411 """
412 return self._resolve_loudness_normalization()
413
414 def delivers_crossfaded_audio(self, streamdetails: StreamDetails) -> bool:
415 """
416 Return whether Spotify applies crossfade to this source.
417
418 :param streamdetails: Stream details of the active Spotify Connect source.
419 """
420 return self._resolve_crossfade_ms() > 0
421
422 async def on_source_selected(
423 self,
424 source_id: str,
425 player_id: str,
426 owner_player_id: str,
427 stream_session_id: str,
428 ) -> None:
429 """Handle callback when this AudioSource has been selected/started on a player."""
430 daemon = self._daemons.get(source_id)
431 if daemon is None or not player_id:
432 return
433
434 # Cache the owner_player_id (== user-facing MA player) rather than the
435 # protocol-level player_id. Some protocol players are ephemeral bridges
436 # whose ID is invalid for play_media / queue lookups once torn down.
437 active_player_id = owner_player_id
438 prev_player_id = (
439 daemon.active_player_id if daemon.active_player_id != active_player_id else None
440 )
441
442 # Claim ownership for this queue BEFORE kicking the previous player: the
443 # awaited stop below can complete the old stream's teardown, and only an
444 # already-replaced session id lets on_source_unselected's stale-guard
445 # reject that teardown â otherwise it releases the Spotify session this
446 # handover is about to use.
447 daemon.in_use_by_player = owner_player_id
448 daemon.active_session_id = stream_session_id
449 daemon.active_player_id = active_player_id
450 self.logger.debug("Active player set to: %s", active_player_id)
451
452 # If a different player was consuming the source, kick it out (the source
453 # is exclusive).
454 if prev_player_id:
455 self.logger.info(
456 "Source selected on player %s, stopping playback on %s",
457 active_player_id,
458 prev_player_id,
459 )
460 try:
461 await self.mass.players.cmd_stop(prev_player_id)
462 except Exception as err:
463 self.logger.debug("Failed to stop previous player %s: %s", prev_player_id, err)
464
465 # Push the options the session reported before this claim existed, so the
466 # queue mirrors the session's shuffle/repeat state from the start.
467 if daemon.last_playback_options is not None:
468 self.mass.players.update_source_options(
469 owner_player_id,
470 daemon.player_id,
471 self.instance_id,
472 shuffle_enabled=daemon.last_playback_options.shuffle,
473 repeat_mode=daemon.last_playback_options.repeat,
474 )
475
476 # Externally triggered: the backend is already playing â nothing to do.
477 # Otherwise acquire playback, then confirm it actually started.
478 if not daemon.playing:
479 try:
480 if daemon.spotify_session_active:
481 # Still the active Spotify device (just paused) â resume.
482 await daemon.backend.resume()
483 elif daemon.last_context_uri:
484 # The user moved the active device away in the Spotify app.
485 # Take playback back by (re)starting the last context on us,
486 # which makes this device the active one again. The track
487 # restarts from its beginning (there is no resume-at-position
488 # play call).
489 self.logger.info("Taking Spotify playback back to Music Assistant")
490 await daemon.backend.play(
491 daemon.last_context_uri, skip_to_uri=daemon.last_track_uri
492 )
493 else:
494 raise self._not_active_error(daemon)
495 except AudioError:
496 raise
497 except Exception as err:
498 raise AudioError(f"Failed to acquire Spotify Connect: {err}") from err
499 if not await self._wait_for_playing(daemon):
500 raise self._not_active_error(daemon)
501
502 # The backend reports 100% volume until told otherwise; push the player's
503 # volume so the Spotify app's absolute volume commands start from the
504 # real level.
505 await self._sync_player_volume_to_spotify(daemon, active_player_id)
506
507 async def on_source_unselected(
508 self, source_id: str, owner_player_id: str, stream_session_id: str
509 ) -> None:
510 """Release the queue-scoped exclusive claim when MA tears down the stream."""
511 daemon = self._daemons.get(source_id)
512 if daemon is None:
513 return
514 # Reject stale callbacks: only release if this is still the active
515 # session. A owner_player_id check alone is not sufficient â same-queue
516 # reconnects would otherwise let an old request's late callback clear
517 # the live claim of the new stream.
518 if daemon.active_session_id != stream_session_id:
519 return
520 daemon.active_session_id = None
521 if daemon.in_use_by_player == owner_player_id:
522 daemon.in_use_by_player = None
523 if daemon.playing:
524 # MA-side stop/queue-clear: release the Spotify session so the app
525 # drops the device as its playback target â the daemon would
526 # otherwise keep playing into a pipe nobody consumes and the app
527 # would stay tethered to the device. (Teardowns caused by a
528 # Spotify-side pause, deselect or a player handoff never reach
529 # here: those cleared the playing flag or replaced the session id first.)
530 try:
531 await daemon.backend.deactivate()
532 except Exception as err:
533 self.logger.debug("Failed to release Spotify session on stream teardown: %s", err)
534
535 async def on_source_released(self, source_id: str, player_id: str) -> None:
536 """Release the Spotify session when a player is done with this source."""
537 daemon = self._daemons.get(source_id)
538 if daemon is None or daemon.active_player_id != player_id:
539 return
540 if not daemon.spotify_session_active:
541 return
542 # Released whether or not a stream is still winding down: a paused source
543 # already ended its stream, so its teardown released nothing and the
544 # Spotify app would stay tethered to a player that has moved on.
545 #
546 # Let the player go first. The backend answers a deactivate with the same
547 # 'inactive' event a deselect in the Spotify app produces, and that stops
548 # the player we were on - which by now is playing whatever took our place.
549 daemon.active_player_id = None
550 try:
551 await daemon.backend.deactivate()
552 except Exception as err:
553 self.logger.debug("Failed to release Spotify session: %s", err)
554
555 async def on_source_control(
556 self,
557 source_id: str,
558 action: SourceControl,
559 value: SourceControlValue = None,
560 ) -> None:
561 """Proxy playback control commands to the backend."""
562 daemon = self._daemons.get(source_id)
563 if daemon is None:
564 return
565 if not daemon.playing and not daemon.spotify_session_active:
566 raise self._not_active_error(daemon)
567 try:
568 if action == SourceControl.PLAY:
569 await daemon.backend.resume()
570 elif action == SourceControl.PAUSE:
571 await daemon.backend.pause()
572 elif action == SourceControl.NEXT:
573 await daemon.backend.next()
574 elif action == SourceControl.PREVIOUS:
575 await daemon.backend.previous()
576 elif (
577 action == SourceControl.SEEK
578 # tolerate float positions from internal callers; bool is an int
579 # subclass, so a misrouted toggle must not become a 1-second seek
580 and isinstance(value, (int, float))
581 and not isinstance(value, bool)
582 ):
583 await daemon.backend.seek(int(value) * 1000)
584 elif action == SourceControl.SHUFFLE and isinstance(value, bool):
585 # strict bool: None or a misrouted enum (bool(RepeatMode.OFF) is
586 # True) must not silently toggle shuffle
587 await daemon.backend.set_shuffle(value)
588 elif action == SourceControl.REPEAT and isinstance(value, RepeatMode):
589 await daemon.backend.set_repeat(value)
590 except Exception as err:
591 self.logger.warning("Failed to send %s command to backend: %s", action, err)
592 raise
593
594 async def on_volume_change(self, source_id: str, volume: int) -> None:
595 """Sync the Spotify app's volume slider with the player's new volume."""
596 daemon = self._daemons.get(source_id)
597 if daemon is None:
598 return
599 if not daemon.playing and not daemon.spotify_session_active:
600 raise self._not_active_error(daemon)
601 # Prevent ping-pong: only push if the value actually changed from what we
602 # last sent to / received from the backend.
603 if daemon.last_volume_sent == volume:
604 return
605 try:
606 await self._push_volume_to_backend(daemon, volume)
607 except Exception as err:
608 self.logger.warning("Failed to send volume command to backend: %s", err)
609 raise
610
611 async def _on_player_event(self, event: MassEvent) -> None:
612 """Reconcile the Connect daemons after a connected player's lifecycle event."""
613 if self._unload_called:
614 return
615 if event.event == EventType.PLAYER_REMOVED:
616 # permanent removal: stop the daemon; a temporarily unavailable player
617 # (which fires only PLAYER_UPDATED) keeps its running daemon so the
618 # advertised device identity stays stable across the outage
619 async with self._reconcile_lock:
620 if event.object_id and (daemon := self._daemons.pop(event.object_id, None)):
621 # the session may be consumed by ANOTHER player (cross-select or
622 # sync-group owner); release it so that player is not left bound
623 # to a source that can no longer stream
624 self._clear_active_player(daemon)
625 await self._stop_daemon(daemon)
626 return
627 await self._reconcile()
628
629 async def _reconcile(self) -> None:
630 """
631 Align the running Connect daemons with the connected players.
632
633 Starts a daemon for every connected player that is registered, and restarts
634 a daemon whose advertised name drifted from the player's current name.
635 """
636 async with self._reconcile_lock:
637 if self._unload_called:
638 return
639 template = self.get_config_value(CONF_PUBLISH_NAME_TEMPLATE)
640 for player_id in self._assigned_player_ids:
641 player = self.mass.players.get_player(player_id)
642 if player is None:
643 # not (yet) registered: never start a daemon for it; an already
644 # running one is deliberately kept (see _on_player_event)
645 continue
646 publish_name = resolve_publish_name(template, player.display_name)
647 daemon = self._daemons.get(player_id)
648 if daemon is not None and daemon.publish_name == publish_name:
649 continue
650 if daemon is not None:
651 # the advertised name follows the player name: restart on rename.
652 # A live session is released first so the consuming player's queue
653 # is not left held by a source the replaced daemon cannot stream
654 # (unload and player removal already release via the controller).
655 self._clear_active_player(daemon)
656 del self._daemons[player_id]
657 await self._stop_daemon(daemon)
658 await self._start_daemon(player, publish_name)
659 # the standing source entry feeds the player's cached source list
660 self.mass.players.trigger_player_update(player_id)
661
662 async def _start_daemon(self, player: Player, publish_name: str) -> None:
663 """
664 Create the daemon state for a connected player and start its backend.
665
666 :param player: The (registered) player this daemon plays on.
667 :param publish_name: The device name to advertise in the Spotify app.
668 """
669 player_id = player.player_id
670 daemon = _PlayerDaemon(
671 player_id=player_id,
672 safe_player_id=re.sub(r"[^A-Za-z0-9_.-]", "_", player_id),
673 publish_name=publish_name,
674 stream_metadata=StreamMetadata(title=f"Spotify Connect | {publish_name}"),
675 )
676 daemon.backend = self._create_backend(daemon, player.display_name)
677 daemon.audio_source = self._build_audio_source(daemon, player.display_name)
678 self._daemons[player_id] = daemon
679 try:
680 await daemon.backend.start()
681 except Exception as err:
682 # a daemon that cannot start makes the whole provider unusable
683 # (shared engine setup); surface it like a backend fatal error
684 self._daemons.pop(player_id, None)
685 self.unload_with_error(err)
686
687 async def _stop_daemon(self, daemon: _PlayerDaemon) -> None:
688 """Stop a daemon's backend and cancel its pending tasks."""
689 daemon.stop_called = True
690 self._cancel_pending_play_media(daemon)
691 self._cancel_pending_pause_stop(daemon)
692 await daemon.backend.stop()
693
694 def _create_backend(self, daemon: _PlayerDaemon, player_name: str) -> SpotifyConnectBackend:
695 """
696 Construct the configured Spotify Connect backend implementation.
697
698 :param daemon: The daemon state the backend belongs to.
699 :param player_name: The connected player's display name (log labels).
700 """
701 # One backend per connected player: the identity key derives the
702 # per-player credential/cache dirs and the stable Spotify device id.
703 identity_key = f"{self.instance_id}_{daemon.safe_player_id}"
704 log_label = f"{self.name}/{player_name}"
705 event_callback = partial(self._handle_backend_event, daemon)
706 # The backend choice and soloist secrets are collected by the setup flow
707 # into setup_data; a config migrated from before the backend choice
708 # existed yields None here, which intentionally selects go-librespot
709 # (the equality check must keep treating None as the default).
710 if self.get_setup_value(CONF_BACKEND) == BACKEND_SOLOIST:
711 return SoloistBackend(
712 self.mass,
713 identity_key=identity_key,
714 publish_name=daemon.publish_name,
715 name=log_label,
716 logger=self.logger,
717 event_callback=event_callback,
718 api_key=cast("str", self.get_setup_value(CONF_API_KEY) or ""),
719 consent=bool(self.get_setup_value(CONF_SOLOIST_CONSENT)),
720 volume_mode=self._resolve_volume_mode(),
721 crossfade_ms=self._resolve_crossfade_ms(),
722 loudness_normalization=self._resolve_loudness_normalization(),
723 audio_quality=self._resolve_audio_quality(),
724 )
725 return GoLibrespotBackend(
726 self.mass,
727 identity_key=identity_key,
728 publish_name=daemon.publish_name,
729 name=log_label,
730 logger=self.logger,
731 event_callback=event_callback,
732 crossfade_ms=self._resolve_crossfade_ms(),
733 loudness_normalization=self._resolve_loudness_normalization(),
734 audio_quality=self._resolve_audio_quality(),
735 )
736
737 def _resolve_volume_mode(self) -> str:
738 """Return the configured volume mode (the provider options page is the only source)."""
739 return cast(
740 "str",
741 self.config.get_value(CONF_VOLUME_MODE) or VOLUME_MODE_PLAYER_ONLY,
742 )
743
744 def _resolve_crossfade_ms(self) -> int:
745 """Return the configured crossfade duration in milliseconds (0 = disabled)."""
746 value = cast("int | None", self.config.get_value(CONF_CROSSFADE_DURATION))
747 return max(0, min(int(value or 0), MAX_CROSSFADE_DURATION)) * 1000
748
749 def _resolve_loudness_normalization(self) -> bool:
750 """Return whether Spotify's loudness normalization should be enabled."""
751 value = self.config.get_value(CONF_LOUDNESS_NORMALIZATION)
752 return True if value is None else bool(value)
753
754 def _resolve_audio_quality(self) -> str:
755 """Return the configured streaming quality tier."""
756 value = self.config.get_value(CONF_AUDIO_QUALITY)
757 if value in AUDIO_QUALITY_VALUES:
758 return cast("str", value)
759 return AUDIO_QUALITY_LOSSLESS
760
761 def _not_active_error(self, daemon: _PlayerDaemon) -> AudioError:
762 """Build the localized 'not the active Spotify device' error, naming the device."""
763 return AudioError(
764 NOT_ACTIVE_DEVICE_MESSAGE.format(daemon.publish_name),
765 translation_key="not_active_device",
766 translation_args=[daemon.publish_name],
767 translation_owner=self.translation_owner,
768 )
769
770 def _build_audio_source(self, daemon: _PlayerDaemon, player_name: str) -> AudioSource:
771 """
772 Construct the AudioSource MediaItem for a daemon.
773
774 Backends provide a full control surface, so play / pause / seek /
775 next / previous are always available while a session is active â the
776 capability flags are static (no dependency on the Spotify Web API).
777 Ordering the session is only offered by backends implementing the
778 queue-session verbs.
779
780 :param daemon: The daemon state the source belongs to.
781 :param player_name: The connected player's display name.
782 """
783 return AudioSource(
784 # the player id is stable across renames, so the source uri survives them
785 item_id=daemon.player_id,
786 provider=self.instance_id,
787 name=f"{self.name} ({player_name})",
788 provider_mappings={
789 ProviderMapping(
790 item_id=daemon.player_id,
791 provider_domain=self.domain,
792 provider_instance=self.instance_id,
793 audio_format=daemon.backend.audio_format,
794 )
795 },
796 can_play_pause=True,
797 can_seek=True,
798 can_next_previous=True,
799 can_shuffle=daemon.backend.supports_queue_control,
800 can_repeat=daemon.backend.supports_queue_control,
801 exclusive=True,
802 allow_external_trigger=True,
803 # Browsable/startable from MA: playback resumes the last known
804 # Spotify context (claiming active device status). Without any
805 # prior context a localized error points the user to the app.
806 can_initiate=True,
807 )
808
809 async def _wait_for_playing(
810 self, daemon: _PlayerDaemon, timeout: float = PLAYBACK_START_TIMEOUT_S
811 ) -> bool:
812 """
813 Wait up to ``timeout`` seconds for the backend to report it is playing.
814
815 :param daemon: The daemon whose backend was asked to play.
816 :param timeout: Maximum seconds to wait.
817 :return: True once playback is confirmed, False if the timeout elapses.
818 """
819 deadline = self.mass.loop.time() + timeout
820 while True:
821 if daemon.playing:
822 return True
823 if self.mass.loop.time() >= deadline:
824 return False
825 await asyncio.sleep(0.1)
826
827 async def _stop_paused_player(self, player_id: str) -> None:
828 """
829 Stop the active player after a pause on a backend without stream EOF.
830
831 :param player_id: The player currently consuming the live source.
832 """
833 self.logger.debug("Stopping player %s after pause", player_id)
834 started = self.mass.loop.time()
835 try:
836 await self.mass.players.cmd_stop(player_id)
837 except Exception as err:
838 self.logger.debug("Failed to stop player %s on pause: %s", player_id, err)
839 return
840 # a timeout around the stop is not enforceable: the process cleanup it waits on
841 # can swallow the cancellation (see AsyncProcess.close), so a slow stop is reported
842 if (elapsed := self.mass.loop.time() - started) > SLOW_STOP_WARN_S:
843 self.logger.warning("Stopping player %s took %.1f seconds", player_id, elapsed)
844 else:
845 self.logger.debug("Player %s stopped after pause", player_id)
846
847 def _schedule_play_media(self, daemon: _PlayerDaemon) -> None:
848 """Schedule playback when Spotify is active and no player owns the source."""
849 if (
850 not daemon.playing
851 or not daemon.spotify_session_active
852 or daemon.in_use_by_player
853 or (
854 daemon.pending_play_media_task is not None
855 and not daemon.pending_play_media_task.done()
856 )
857 ):
858 return
859 daemon.pending_play_media_task = self.mass.create_task(
860 self._deferred_play_media_fire(daemon)
861 )
862
863 def _cancel_pending_play_media(self, daemon: _PlayerDaemon) -> None:
864 """Cancel any pending deferred play_media trigger."""
865 task = daemon.pending_play_media_task
866 if task is not None and not task.done():
867 task.cancel()
868 daemon.pending_play_media_task = None
869
870 def _schedule_pause_stop(self, daemon: _PlayerDaemon, player_id: str) -> None:
871 """
872 Dispatch the stop of the paused player, replacing a still-pending one.
873
874 :param daemon: The daemon whose consuming player paused.
875 :param player_id: The player currently consuming the live source.
876 """
877 self._cancel_pending_pause_stop(daemon)
878 task = self.mass.create_task(self._stop_paused_player(player_id))
879 daemon.pending_pause_stop_task = task
880 task.add_done_callback(partial(self._on_pause_stop_done, daemon))
881
882 def _cancel_pending_pause_stop(self, daemon: _PlayerDaemon) -> None:
883 """Cancel any pending deferred stop of a paused player."""
884 task = daemon.pending_pause_stop_task
885 if task is not None and not task.done():
886 task.cancel()
887 daemon.pending_pause_stop_task = None
888
889 def _on_pause_stop_done(self, daemon: _PlayerDaemon, task: asyncio.Task[None]) -> None:
890 """Drop the pause-stop handle once its task finished (unless already replaced)."""
891 if daemon.pending_pause_stop_task is task:
892 daemon.pending_pause_stop_task = None
893
894 async def _deferred_play_media_fire(self, daemon: _PlayerDaemon) -> None:
895 """
896 Trigger play_media after a short debounce.
897
898 The backend can emit a stale 'playing' from a dying session just before it
899 reconnects; acting on it immediately would start a stream for a session
900 that is about to be replaced. Debouncing â and cancelling the task on a
901 later 'paused' / 'stopped' / 'active' event â avoids a playâstopâreplay loop.
902 """
903 try:
904 await asyncio.sleep(PLAY_MEDIA_DEBOUNCE_S)
905 except asyncio.CancelledError:
906 return
907 if not daemon.playing or daemon.in_use_by_player:
908 return
909 # an explicitly selected player wins, else the daemon's own connected player
910 target_player_id = daemon.active_player_id or daemon.player_id
911 self.logger.info(
912 "Starting Spotify Connect playback [%s] on player %s",
913 daemon.publish_name,
914 target_player_id,
915 )
916 daemon.active_player_id = target_player_id
917 self.mass.create_task(
918 self.mass.player_queues.play_media(target_player_id, str(daemon.audio_source.uri))
919 )
920
921 def _clear_active_player(self, daemon: _PlayerDaemon) -> None:
922 """Clear the active player and reset playback state when a session ends."""
923 prev_player_id = daemon.active_player_id
924 source_session = (
925 self.mass.players.get_audio_source_session(prev_player_id) if prev_player_id else None
926 )
927 daemon.active_player_id = None
928 daemon.in_use_by_player = None
929 daemon.active_session_id = None
930 daemon.playing = False
931 if prev_player_id:
932 self.logger.debug("Playback ended on player %s, clearing active player", prev_player_id)
933 # the player is not playing us any more, so it should stop saying it is;
934 # the stop itself is scheduled separately by the caller
935 self.mass.create_task(
936 self.mass.players.deselect_source(
937 prev_player_id,
938 stop_playback=False,
939 provider_instance_id=self.instance_id,
940 source_id=daemon.player_id,
941 playback_session_id=(
942 source_session.playback_session_id if source_session else None
943 ),
944 )
945 )
946
947 async def _handle_backend_event(self, daemon: _PlayerDaemon, event: BackendEvent) -> None:
948 """Dispatch a single normalized event received from a daemon's backend."""
949 if event.type is BackendEventType.CONNECTION_LOST:
950 # The backend's Spotify session is gone (e.g. daemon exit). Reset
951 # session state so a dead/restarting backend isn't treated as active
952 # and controllable; a fresh 'active' event re-establishes it.
953 daemon.playing = False
954 daemon.spotify_session_active = False
955 # stale options must not outlive the session they belong to
956 daemon.last_playback_options = None
957 return
958 if event.type is BackendEventType.FATAL_ERROR:
959 # a deliberately stopped daemon (unload, rename restart, player
960 # removal) must not tear down the whole provider
961 if not daemon.stop_called:
962 self.unload_with_error(event.error or "Spotify Connect backend failed")
963 return
964 if event.type is BackendEventType.ERROR:
965 # non-fatal backend error: surface it in the log only
966 self.logger.warning("Spotify Connect backend error: %s", event.error)
967 return
968
969 self._remember_context_uris(daemon, event)
970
971 if event.type is BackendEventType.QUEUE_CHANGED:
972 # queue snapshots are not consumed yet (full queue-item mirroring comes later)
973 return
974 if event.type is BackendEventType.OPTIONS_CHANGED:
975 # an options report is no reason to re-push the (unchanged) stream metadata below
976 self._handle_options_changed(daemon, event)
977 return
978
979 if event.type is BackendEventType.SESSION_ACTIVE:
980 daemon.spotify_session_active = True
981 daemon.last_session_active_time = time.time()
982 # A (re)activation supersedes any deferred play_media from a previous
983 # session. Reconcile afterwards because 'playing' may arrive first.
984 self._cancel_pending_play_media(daemon)
985 self.logger.info("Spotify Connect session active for %s", daemon.publish_name)
986 # A new session starts at the backend's 100% volume default; push the
987 # target player's volume so the Spotify app's slider is correct from
988 # device selection, before any playback starts. (In the soloist
989 # player_only mode the backend pins 100% and ignores the pushed
990 # value â the app slider staying at 100 there is by design.)
991 await self._sync_player_volume_to_spotify(
992 daemon, daemon.active_player_id or daemon.player_id
993 )
994 self._schedule_play_media(daemon)
995 elif event.type is BackendEventType.SESSION_INACTIVE:
996 self.logger.info("Spotify Connect session inactive for %s", daemon.publish_name)
997 daemon.spotify_session_active = False
998 # stale options must not outlive the session they belong to
999 daemon.last_playback_options = None
1000 prev_player_id = daemon.active_player_id
1001 self._clear_active_player(daemon)
1002 if prev_player_id:
1003 self._schedule_pause_stop(daemon, prev_player_id)
1004 return
1005 elif event.type is BackendEventType.PLAYING:
1006 daemon.playing = True
1007 # A resume can arrive while the pause-stop is still in flight on a
1008 # slow player; cancel it so it doesn't kill the restarted stream.
1009 # (a stop that already completed is fine: play_media below restarts)
1010 self._cancel_pending_pause_stop(daemon)
1011 # Externally triggered playback: kick a play_media on the target MA
1012 # player so the audio reaches a speaker. Deferred so a rapid
1013 # playing/active burst from a reconnecting session can cancel it.
1014 # Only while the session is active: a daemon playing without being
1015 # the active Connect device (e.g. right after a deactivate) must
1016 # not grab MA players in a loop.
1017 self._schedule_play_media(daemon)
1018 elif event.type in (BackendEventType.PAUSED, BackendEventType.STOPPED):
1019 was_playing = daemon.playing
1020 daemon.playing = False
1021 # A pause/stop is the definitive "don't start": cancel a deferred fire
1022 # from a now-stale 'playing'. On a backend whose stream ends on pause the
1023 # active get_audio_stream sees the PCM stop and ends the stream (clean
1024 # EOF), so the player leaves the playing state and the next 'playing'
1025 # event re-fires play_media to resume.
1026 self._cancel_pending_play_media(daemon)
1027 # A backend without a stream end on pause never signals EOF, so
1028 # the player must be stopped actively; the claim stays so the next
1029 # 'playing' event resumes playback like the EOF path does. Only the
1030 # playingâpaused transition fires it: the backend reports a pause
1031 # through multiple events (state delta + snapshot).
1032 if (
1033 was_playing
1034 and not daemon.backend.stream_ends_on_pause
1035 and (player_id := daemon.active_player_id)
1036 ):
1037 self._schedule_pause_stop(daemon, player_id)
1038
1039 if event.type is BackendEventType.METADATA and event.metadata is not None:
1040 self._apply_metadata(daemon, event.metadata)
1041 elif event.type is BackendEventType.POSITION and event.position is not None:
1042 daemon.stream_metadata.elapsed_time = event.position
1043 daemon.stream_metadata.elapsed_time_last_updated = int(time.time())
1044
1045 if event.type is BackendEventType.VOLUME and event.volume is not None:
1046 await self._handle_volume_event(daemon, event.volume)
1047
1048 # push metadata update to the active queue item's streamdetails
1049 if daemon.in_use_by_player:
1050 self.mass.players.update_source_metadata(
1051 daemon.in_use_by_player,
1052 daemon.player_id,
1053 self.instance_id,
1054 daemon.stream_metadata,
1055 )
1056
1057 def _remember_context_uris(self, daemon: _PlayerDaemon, event: BackendEvent) -> None:
1058 """
1059 Memoize the latest context/track URIs seen on the event stream.
1060
1061 Used to take playback back (make MA the active Spotify device) when the user
1062 switched the active device away in the Spotify app and then presses play in MA
1063 (see ``on_source_selected``).
1064
1065 :param daemon: The daemon the event originates from.
1066 :param event: The backend event to read the URIs from.
1067 """
1068 if event.context_uri:
1069 daemon.last_context_uri = event.context_uri
1070 if event.track_uri:
1071 daemon.last_track_uri = event.track_uri
1072
1073 def _handle_options_changed(self, daemon: _PlayerDaemon, event: BackendEvent) -> None:
1074 """
1075 Cache the session's playback options and mirror them onto the consuming queue.
1076
1077 :param daemon: The daemon the event originates from.
1078 :param event: The OPTIONS_CHANGED event to handle.
1079 """
1080 if event.options is None:
1081 return
1082 # cache regardless of claim state: an externally triggered session reports its
1083 # options before the queue claim exists; on_source_selected pushes the cached
1084 # value once claimed
1085 daemon.last_playback_options = event.options
1086 if not daemon.in_use_by_player:
1087 return
1088 self.mass.players.update_source_options(
1089 daemon.in_use_by_player,
1090 daemon.player_id,
1091 self.instance_id,
1092 shuffle_enabled=event.options.shuffle,
1093 repeat_mode=event.options.repeat,
1094 )
1095
1096 def _apply_metadata(self, daemon: _PlayerDaemon, metadata: BackendTrackMetadata) -> None:
1097 """Update a daemon's live StreamMetadata from a normalized metadata event."""
1098 daemon.stream_metadata.uri = metadata.track_uri
1099 if metadata.title:
1100 daemon.stream_metadata.title = metadata.title
1101 daemon.stream_metadata.artist = metadata.artist
1102 daemon.stream_metadata.album = metadata.album
1103 daemon.stream_metadata.image_url = metadata.image_url
1104 daemon.stream_metadata.description = None
1105 daemon.stream_metadata.duration = metadata.duration
1106 daemon.stream_metadata.elapsed_time = metadata.position
1107 daemon.stream_metadata.elapsed_time_last_updated = int(time.time())
1108
1109 async def _handle_volume_event(self, daemon: _PlayerDaemon, volume: int) -> None:
1110 """
1111 Apply a Spotify-side volume change to the linked MA player.
1112
1113 :param daemon: The daemon the volume change originates from.
1114 :param volume: The reported volume as a 0-100 percentage.
1115 """
1116 # Ignore our own echo: the backend emits a 'volume' event for the value we
1117 # just pushed in on_volume_change; re-applying it would ping-pong.
1118 if volume == daemon.last_volume_sent:
1119 return
1120 # Ignore the volume the backend reports right after a session becomes
1121 # active â the player's own volume should win in that window.
1122 if time.time() - daemon.last_session_active_time < INITIAL_VOLUME_GRACE_S:
1123 self.logger.debug("Ignoring initial volume_changed event after session active")
1124 return
1125 if not daemon.in_use_by_player:
1126 return
1127 previous_volume = daemon.last_volume_sent
1128 daemon.last_volume_sent = volume
1129 try:
1130 await self.mass.players.cmd_volume_set(daemon.in_use_by_player, volume)
1131 except Exception as err:
1132 # Volume sync is best-effort: the player may not support volume, or the
1133 # command may fail. Restore the cached value so a retry isn't wrongly
1134 # deduped, and never let it bubble up and drop the events loop.
1135 daemon.last_volume_sent = previous_volume
1136 self.logger.debug("Could not set volume on %s: %s", daemon.in_use_by_player, err)
1137
1138 async def _sync_player_volume_to_spotify(self, daemon: _PlayerDaemon, player_id: str) -> None:
1139 """
1140 Push a player's current volume to the backend (best-effort).
1141
1142 :param daemon: The daemon to push the volume to.
1143 :param player_id: The MA player whose volume to push.
1144 """
1145 player = self.mass.players.get_player(player_id)
1146 if player is None or player.state.volume_level is None:
1147 return
1148 # clamp: the logical volume can be out of range until volume limit
1149 # enforcement runs
1150 volume = max(0, min(100, player.state.volume_level))
1151 # No dedupe against last_volume_sent here: it holds the last value
1152 # exchanged with the backend, not the backend's current volume, which
1153 # resets to its 100% default on a new session or backend restart.
1154 try:
1155 await self._push_volume_to_backend(daemon, volume)
1156 except Exception as err:
1157 self.logger.debug("Failed to sync player volume to Spotify: %s", err)
1158
1159 async def _push_volume_to_backend(self, daemon: _PlayerDaemon, volume: int) -> None:
1160 """
1161 Send an absolute 0-100 volume to the backend.
1162
1163 :param daemon: The daemon to send the volume to.
1164 :param volume: Volume percentage to send.
1165 :raises Exception: If the request to the backend fails.
1166 """
1167 previous_volume = daemon.last_volume_sent
1168 # Record BEFORE the call: the backend echoes a 'volume' event back, and
1169 # that echo can arrive over the event stream while we're still awaiting
1170 # set_volume. Recording up front lets _handle_volume_event dedupe it
1171 # instead of bouncing it back as a player volume change.
1172 daemon.last_volume_sent = volume
1173 try:
1174 await daemon.backend.set_volume(volume)
1175 except Exception:
1176 # restore on failure so a retry of this value isn't wrongly deduped
1177 daemon.last_volume_sent = previous_volume
1178 raise
1179