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