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