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