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