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