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