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