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