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