/
/
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, cast
16
17from music_assistant_models.enums import (
18 MediaType,
19 PlaybackState,
20 ProviderFeature,
21 SourceControl,
22 StreamType,
23)
24from music_assistant_models.errors import AudioError, MediaNotFoundError
25from music_assistant_models.media_items import AudioSource, ProviderMapping
26from music_assistant_models.streamdetails import StreamDetails, StreamMetadata
27
28from music_assistant.constants import CONF_ENTRY_WARN_PREVIEW
29from music_assistant.models.plugin import PluginProvider
30
31from .backends.go_librespot import GoLibrespotBackend
32from .models import BackendEventType
33
34if TYPE_CHECKING:
35 from collections.abc import AsyncGenerator
36
37 from music_assistant_models.config_entries import ConfigEntry, ProviderConfig
38 from music_assistant_models.provider import ProviderManifest
39
40 from music_assistant.mass import MusicAssistant
41
42 from .backends.base import SpotifyConnectBackend
43 from .models import BackendEvent, BackendTrackMetadata
44
45CONF_MASS_PLAYER_ID = "mass_player_id"
46CONF_PUBLISH_NAME = "publish_name"
47DEFAULT_PUBLISH_NAME = "Music Assistant"
48
49# Special value for auto player selection
50PLAYER_ID_AUTO = "__auto__"
51
52SUPPORTED_FEATURES = {ProviderFeature.AUDIO_SOURCE}
53
54# stable id for the single AudioSource this provider exposes;
55# combined with the provider instance_id this forms the persistent uri
56AUDIO_SOURCE_ID = "main"
57
58# When playback is paused the backend stops writing PCM. If no PCM arrives for
59# this long while we're not in a 'playing' state, end the stream (clean EOF) so
60# the player leaves the playing state; the next 'playing' event re-streams.
61PAUSE_EOF_TIMEOUT_S = 0.5
62
63# Seconds to wait for the backend to report 'playing' after a resume request.
64PLAYBACK_START_TIMEOUT_S = 3.0
65
66# Debounce before acting on an externally-triggered 'playing' event (see
67# _deferred_play_media_fire for why).
68PLAY_MEDIA_DEBOUNCE_S = 0.5
69
70# Ignore Spotify volume events for this long after a session becomes active, so
71# the player's own volume wins over the backend's initial value on (re)connect.
72INITIAL_VOLUME_GRACE_S = 3.0
73
74# User-facing message for the "not the active Spotify device" failure.
75# {0} is the Spotify Connect device's published name (see _not_active_error).
76NOT_ACTIVE_DEVICE_MESSAGE = (
77 "'{0}' is not the active Spotify playback device. "
78 "Open the Spotify app, select it as the playback device, and try again."
79)
80
81
82class SpotifyConnectProvider(PluginProvider):
83 """Implementation of a Spotify Connect Plugin (backed by a SpotifyConnectBackend)."""
84
85 reload_on_streams_network_change = True
86
87 def __init__(
88 self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
89 ) -> None:
90 """Initialize MusicProvider."""
91 super().__init__(mass, manifest, config, SUPPORTED_FEATURES)
92 # Configured default player (PLAYER_ID_AUTO or a specific player id)
93 self._default_player_id: str = (
94 cast("str", self.get_setup_value(CONF_MASS_PLAYER_ID)) or PLAYER_ID_AUTO
95 )
96 self._publish_name = (
97 cast("str", self.get_setup_value(CONF_PUBLISH_NAME)) or DEFAULT_PUBLISH_NAME
98 )
99 # Currently active player (the one currently playing or selected)
100 self._active_player_id: str | None = None
101 self._backend: SpotifyConnectBackend = GoLibrespotBackend(
102 mass,
103 instance_id=self.instance_id,
104 publish_name=self._publish_name,
105 name=self.name,
106 logger=self.logger,
107 event_callback=self._handle_backend_event,
108 )
109 self.logger.debug(
110 "Init plugin with name '%s' for player '%s' with instance id '%s'",
111 self.name,
112 self._default_player_id,
113 self.instance_id,
114 )
115 self._stream_metadata = StreamMetadata(title=f"Spotify Connect | {self._publish_name}")
116 self._audio_source = self._build_audio_source()
117 # _in_use_by_queue is the queue currently streaming us. Claimed in
118 # on_source_selected (NOT in get_stream_details â that path also runs
119 # from queue preload, where claiming would block a later cross-queue
120 # handoff). Released in on_source_unselected when the session id
121 # matches, or in _clear_active_player on the backend's 'inactive' event.
122 self._in_use_by_queue: str | None = None
123 # _active_session_id is the controller-provided token for the current
124 # stream request â used to reject stale on_source_unselected callbacks
125 # after a same-queue reconnect supersedes the previous request.
126 self._active_session_id: str | None = None
127 # tracks the backend's play/pause state from its 'playing' / 'paused' /
128 # 'inactive' events; gates the resume kick in on_source_selected (skip if
129 # already playing) and the play_media trigger in the event handler.
130 self._playing: bool = False
131 # True while MA is the active Spotify Connect device (set on 'active',
132 # cleared on 'inactive'); gates get_stream_details and transport commands.
133 self._spotify_session_active: bool = False
134 # holds the single in-flight deferred play_media task scheduled from a
135 # 'playing' event; cancelled when a 'paused' / 'stopped' / 'active' event
136 # arrives during the debounce so we don't act on stale state from a dying
137 # session.
138 self._pending_play_media_task: asyncio.Task[None] | None = None
139 self._last_session_active_time: float = 0
140 self._last_volume_sent: int | None = None
141 # Last context/track URIs seen on the event stream. Used to take playback
142 # back (make ourselves the active Spotify device) when the user switched
143 # the active device away in the Spotify app and then presses play in MA.
144 self._last_context_uri: str | None = None
145 self._last_track_uri: str | None = None
146
147 @property
148 def instance_name_postfix(self) -> str | None:
149 """Return the advertised device name as the multi-instance postfix."""
150 return self._publish_name if self._publish_name != DEFAULT_PUBLISH_NAME else None
151
152 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
153 """Return runtime options for this provider."""
154 return (CONF_ENTRY_WARN_PREVIEW,)
155
156 async def handle_async_init(self) -> None:
157 """Handle async initialization of the provider."""
158 await self._backend.start()
159
160 async def unload(self, is_removed: bool = False) -> None:
161 """Handle close/cleanup of the provider."""
162 self._cancel_pending_play_media()
163 await self._backend.stop()
164
165 @property
166 def active_player_id(self) -> str | None:
167 """Return the currently active player ID for this plugin."""
168 return self._active_player_id
169
170 async def get_audio_sources(self) -> list[AudioSource]:
171 """Return the AudioSources this plugin currently exposes."""
172 return [self._audio_source]
173
174 async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
175 """
176 Return StreamDetails for streaming the Spotify Connect audio.
177
178 Side-effect-free: ownership is claimed in on_source_selected (which the
179 streams controller fires before this method on the actual stream
180 request). Keeping this idempotent means preload paths can fetch
181 streamdetails without claiming the source and blocking a cross-queue
182 handoff.
183
184 Raises AudioError when MA is not the active Spotify Connect device, since
185 playback can only be acquired while a Spotify session is connected to us
186 (entry must come from the Spotify app â see can_initiate below).
187 """
188 if item_id != AUDIO_SOURCE_ID:
189 raise MediaNotFoundError(f"Unknown AudioSource: {item_id}")
190 # Only refuse when we can neither resume nor take playback back. If a last
191 # context is known we let the stream proceed; on_source_selected then takes
192 # playback back (makes us the active device) before audio is pulled.
193 if not self._playing and not self._spotify_session_active and not self._last_context_uri:
194 raise self._not_active_error()
195 # CUSTOM: the core pulls PCM from get_audio_stream. Reading the backend's
196 # audio pipe means a consumer is always attached, and it lets us end the
197 # stream cleanly when playback pauses so the player leaves the playing
198 # state. decoded_audio_format tells the core the PCM format while
199 # audio_format keeps the source codec for display; MA resamples to each
200 # player's format as needed.
201 # `-fflags nobuffer` keeps ffmpeg's own input buffering low so the
202 # controller's realtime pacer owns the (small, bounded) read-ahead.
203 # expiration=0: never reuse a cached streamdetails so the active-device
204 # check above re-runs on every play attempt.
205 return StreamDetails(
206 provider=self.instance_id,
207 item_id=item_id,
208 audio_format=self._backend.audio_format,
209 decoded_audio_format=self._backend.decoded_audio_format,
210 media_type=MediaType.AUDIO_SOURCE,
211 stream_type=StreamType.CUSTOM,
212 stream_metadata=self._stream_metadata,
213 extra_input_args=["-fflags", "nobuffer"],
214 expiration=0,
215 )
216
217 async def get_audio_stream(
218 self,
219 streamdetails: StreamDetails,
220 seek_position: int = 0,
221 ) -> AsyncGenerator[bytes]:
222 """
223 Yield raw PCM from the backend's audio pipe for the live AudioSource.
224
225 When playback pauses the backend stops writing PCM; we then end the
226 stream (clean EOF) so the consuming player leaves the playing state. The
227 next ``playing`` event re-triggers playback. ``seek_position`` is ignored â
228 seeking is handled upstream by Spotify, not by replaying the bytestream.
229 """
230 if streamdetails.item_id != AUDIO_SOURCE_ID:
231 raise MediaNotFoundError(f"Unknown AudioSource: {streamdetails.item_id}")
232 read_chunk = self._backend.get_audio_reader()
233 if read_chunk is None:
234 raise AudioError("Spotify Connect daemon is not running")
235 # No pacing here: the streams controller's realtime pacer (ffmpeg readrate
236 # with a small initial burst) is the single pacing authority for live
237 # sources. Backpressure through the audio pipe bounds how far the backend
238 # (whose pipe backend is not realtime-paced) runs ahead, while the burst
239 # headroom absorbs scheduling jitter that would otherwise underrun the
240 # player. Pacing a second time here would pin the feed to exactly realtime
241 # and starve that headroom.
242 while True:
243 try:
244 chunk = await asyncio.wait_for(read_chunk(), timeout=PAUSE_EOF_TIMEOUT_S)
245 except TimeoutError:
246 # No PCM for a while. If playback is no longer active (paused /
247 # stopped / session gone) end the stream so the player goes idle;
248 # a brief buffering gap while still playing just keeps waiting.
249 if not self._playing:
250 return
251 continue
252 if not chunk:
253 return # audio pipe closed (backend exited / restarting)
254 yield chunk
255
256 async def on_source_selected(
257 self,
258 source_id: str,
259 player_id: str,
260 queue_id: str,
261 stream_session_id: str,
262 ) -> None:
263 """Handle callback when this AudioSource has been selected/started on a player."""
264 if source_id != AUDIO_SOURCE_ID or not player_id:
265 return
266
267 # Cache the queue_id (== user-facing MA player) rather than the
268 # protocol-level player_id. Some protocol players are ephemeral bridges
269 # whose ID is invalid for play_media / queue lookups once torn down.
270 active_player_id = queue_id
271
272 # If there's already a different active player, kick it out. The claim
273 # below replaces the previous queue's claim; the prior stream's
274 # on_source_unselected may fire later, but its session-id guard keeps it
275 # from clobbering the new claim.
276 if self._active_player_id and self._active_player_id != active_player_id:
277 prev_player_id = self._active_player_id
278 self.logger.info(
279 "Source selected on player %s, stopping playback on %s",
280 active_player_id,
281 prev_player_id,
282 )
283 try:
284 await self.mass.players.cmd_stop(prev_player_id)
285 except Exception as err:
286 self.logger.debug("Failed to stop previous player %s: %s", prev_player_id, err)
287
288 # Claim ownership for this queue.
289 self._in_use_by_queue = queue_id
290 self._active_session_id = stream_session_id
291 self._active_player_id = active_player_id
292 self.logger.debug("Active player set to: %s", active_player_id)
293
294 # Only persist the selected player as the new default if not in auto mode
295 if self._default_player_id != PLAYER_ID_AUTO:
296 self._save_last_player_id(active_player_id)
297
298 # Externally triggered: the backend is already playing â nothing to do.
299 # Otherwise acquire playback, then confirm it actually started.
300 if not self._playing:
301 try:
302 if self._spotify_session_active:
303 # Still the active Spotify device (just paused) â resume.
304 await self._backend.resume()
305 elif self._last_context_uri:
306 # The user moved the active device away in the Spotify app.
307 # Take playback back by (re)starting the last context on us,
308 # which makes this device the active one again. The track
309 # restarts from its beginning (there is no resume-at-position
310 # play call).
311 self.logger.info("Taking Spotify playback back to Music Assistant")
312 await self._backend.play(
313 self._last_context_uri, skip_to_uri=self._last_track_uri
314 )
315 else:
316 raise self._not_active_error()
317 except AudioError:
318 raise
319 except Exception as err:
320 raise AudioError(f"Failed to acquire Spotify Connect: {err}") from err
321 if not await self._wait_for_playing():
322 raise self._not_active_error()
323
324 # The backend reports 100% volume until told otherwise; push the player's
325 # volume so the Spotify app's absolute volume commands start from the
326 # real level.
327 await self._sync_player_volume_to_spotify(active_player_id)
328
329 async def on_source_unselected(
330 self, source_id: str, queue_id: str, stream_session_id: str
331 ) -> None:
332 """Release the queue-scoped exclusive claim when MA tears down the stream."""
333 if source_id != AUDIO_SOURCE_ID:
334 return
335 # Reject stale callbacks: only release if this is still the active
336 # session. A queue_id check alone is not sufficient â same-queue
337 # reconnects would otherwise let an old request's late callback clear
338 # the live claim of the new stream.
339 if self._active_session_id != stream_session_id:
340 return
341 self._active_session_id = None
342 if self._in_use_by_queue == queue_id:
343 self._in_use_by_queue = None
344
345 async def on_source_control(
346 self,
347 source_id: str,
348 action: SourceControl,
349 value: int | None = None,
350 ) -> None:
351 """Proxy playback control commands to the backend."""
352 if source_id != AUDIO_SOURCE_ID:
353 return
354 if not self._playing and not self._spotify_session_active:
355 raise self._not_active_error()
356 try:
357 if action == SourceControl.PLAY:
358 await self._backend.resume()
359 elif action == SourceControl.PAUSE:
360 await self._backend.pause()
361 elif action == SourceControl.NEXT:
362 await self._backend.next()
363 elif action == SourceControl.PREVIOUS:
364 await self._backend.previous()
365 elif action == SourceControl.SEEK and value is not None:
366 await self._backend.seek(value * 1000)
367 except Exception as err:
368 self.logger.warning("Failed to send %s command to backend: %s", action, err)
369 raise
370
371 async def on_volume_change(self, source_id: str, volume: int) -> None:
372 """Sync the Spotify app's volume slider with the player's new volume."""
373 if source_id != AUDIO_SOURCE_ID:
374 return
375 if not self._playing and not self._spotify_session_active:
376 raise self._not_active_error()
377 # Prevent ping-pong: only push if the value actually changed from what we
378 # last sent to / received from the backend.
379 if self._last_volume_sent == volume:
380 return
381 try:
382 await self._push_volume_to_backend(volume)
383 except Exception as err:
384 self.logger.warning("Failed to send volume command to backend: %s", err)
385 raise
386
387 def _not_active_error(self) -> AudioError:
388 """Build the localized 'not the active Spotify device' error, naming this device."""
389 return AudioError(
390 NOT_ACTIVE_DEVICE_MESSAGE.format(self._publish_name),
391 translation_key="not_active_device",
392 translation_args=[self._publish_name],
393 translation_owner=self.translation_owner,
394 )
395
396 def _build_audio_source(self) -> AudioSource:
397 """
398 Construct the AudioSource MediaItem.
399
400 Backends provide a full control surface, so play / pause / seek /
401 next / previous are always available while a session is active â the
402 capability flags are static (no dependency on the Spotify Web API).
403 """
404 return AudioSource(
405 item_id=AUDIO_SOURCE_ID,
406 provider=self.instance_id,
407 name=self.name,
408 provider_mappings={
409 ProviderMapping(
410 item_id=AUDIO_SOURCE_ID,
411 provider_domain=self.domain,
412 provider_instance=self.instance_id,
413 audio_format=self._backend.audio_format,
414 )
415 },
416 can_play_pause=True,
417 can_seek=True,
418 can_next_previous=True,
419 exclusive=True,
420 allow_external_trigger=True,
421 # Cold-start from MA is unreliable (Spotify needs an existing
422 # playback context), so only allow external entry via the Spotify app.
423 can_initiate=False,
424 )
425
426 def _get_target_player_id(self) -> str | None:
427 """
428 Determine the target player ID for playback.
429
430 Priority: an explicitly selected player; else (auto) a currently playing
431 player then the first available; else the configured default player.
432
433 :return: The player ID to use for playback, or None if none available.
434 """
435 if self._active_player_id:
436 if self.mass.players.get_player(self._active_player_id):
437 return self._active_player_id
438 self._active_player_id = None
439
440 if self._default_player_id == PLAYER_ID_AUTO:
441 all_players = list(self.mass.players.all_players(False, False))
442 for player in all_players:
443 if player.state.playback_state == PlaybackState.PLAYING:
444 self.logger.debug("Auto-selecting playing player: %s", player.display_name)
445 return player.player_id
446 if all_players:
447 first_player = all_players[0]
448 self.logger.debug(
449 "Auto-selecting first available player: %s", first_player.display_name
450 )
451 return first_player.player_id
452 return None
453
454 if self.mass.players.get_player(self._default_player_id):
455 return self._default_player_id
456 self.logger.warning(
457 "Configured default player '%s' no longer exists", self._default_player_id
458 )
459 return None
460
461 async def _wait_for_playing(self, timeout: float = PLAYBACK_START_TIMEOUT_S) -> bool:
462 """
463 Wait up to ``timeout`` seconds for the backend to report it is playing.
464
465 :param timeout: Maximum seconds to wait.
466 :return: True once playback is confirmed, False if the timeout elapses.
467 """
468 deadline = self.mass.loop.time() + timeout
469 while True:
470 if self._playing:
471 return True
472 if self.mass.loop.time() >= deadline:
473 return False
474 await asyncio.sleep(0.1)
475
476 def _cancel_pending_play_media(self) -> None:
477 """Cancel any pending deferred play_media trigger."""
478 task = self._pending_play_media_task
479 if task is not None and not task.done():
480 task.cancel()
481 self._pending_play_media_task = None
482
483 async def _deferred_play_media_fire(self) -> None:
484 """
485 Trigger play_media after a short debounce.
486
487 The backend can emit a stale 'playing' from a dying session just before it
488 reconnects; acting on it immediately would start a stream for a session
489 that is about to be replaced. Debouncing â and cancelling the task on a
490 later 'paused' / 'stopped' / 'active' event â avoids a playâstopâreplay loop.
491 """
492 try:
493 await asyncio.sleep(PLAY_MEDIA_DEBOUNCE_S)
494 except asyncio.CancelledError:
495 return
496 if not self._playing or self._in_use_by_queue:
497 return
498 target_player_id = self._get_target_player_id()
499 if not target_player_id:
500 self.logger.warning(
501 "Spotify Connect playback started but no player available. "
502 "Select this source on a player to start playback."
503 )
504 return
505 self.logger.info(
506 "Starting Spotify Connect playback [%s] on player %s",
507 self.instance_id,
508 target_player_id,
509 )
510 self._active_player_id = target_player_id
511 self.mass.create_task(
512 self.mass.player_queues.play_media(target_player_id, str(self._audio_source.uri))
513 )
514
515 def _clear_active_player(self) -> None:
516 """Clear the active player and reset playback state when a session ends."""
517 prev_player_id = self._active_player_id
518 self._active_player_id = None
519 self._in_use_by_queue = None
520 self._active_session_id = None
521 self._playing = False
522 if prev_player_id:
523 self.logger.debug("Playback ended on player %s, clearing active player", prev_player_id)
524 self.mass.players.trigger_player_update(prev_player_id)
525
526 def _save_last_player_id(self, player_id: str) -> None:
527 """Persist the selected player ID as the new default."""
528 if self._default_player_id == player_id:
529 return
530 try:
531 self._update_setup_data(CONF_MASS_PLAYER_ID, player_id)
532 self._default_player_id = player_id
533 except Exception as err:
534 self.logger.debug("Failed to persist player ID: %s", err)
535
536 async def _handle_backend_event(self, event: BackendEvent) -> None:
537 """Dispatch a single normalized event received from the backend."""
538 if event.type is BackendEventType.CONNECTION_LOST:
539 # The backend's Spotify session is gone (e.g. daemon exit). Reset
540 # session state so a dead/restarting backend isn't treated as active
541 # and controllable; a fresh 'active' event re-establishes it.
542 self._playing = False
543 self._spotify_session_active = False
544 return
545 if event.type is BackendEventType.FATAL_ERROR:
546 self.unload_with_error(event.error or "Spotify Connect backend failed")
547 return
548
549 # Remember the latest context/track so we can take playback back if the
550 # user moves the active device away in the Spotify app (see on_source_selected).
551 if event.context_uri:
552 self._last_context_uri = event.context_uri
553 if event.track_uri:
554 self._last_track_uri = event.track_uri
555
556 if event.type is BackendEventType.SESSION_ACTIVE:
557 self._spotify_session_active = True
558 self._last_session_active_time = time.time()
559 # A (re)activation supersedes any deferred play_media scheduled from a
560 # previous session's stale 'playing'; the fresh 'playing' that follows
561 # schedules a new one.
562 self._cancel_pending_play_media()
563 self.logger.info("Spotify Connect session active for %s", self.name)
564 # A new session starts at the backend's 100% volume default; push the
565 # target player's volume so the Spotify app's slider is correct from
566 # device selection, before any playback starts.
567 if player_id := self._get_target_player_id():
568 await self._sync_player_volume_to_spotify(player_id)
569 elif event.type is BackendEventType.SESSION_INACTIVE:
570 self.logger.info("Spotify Connect session inactive for %s", self.name)
571 self._spotify_session_active = False
572 prev_player_id = self._active_player_id
573 self._clear_active_player()
574 if prev_player_id:
575 self.mass.create_task(self.mass.players.cmd_stop(prev_player_id))
576 return
577 elif event.type is BackendEventType.PLAYING:
578 self._playing = True
579 # Externally triggered playback: kick a play_media on the target MA
580 # player so the audio reaches a speaker. Deferred so a rapid
581 # playing/active burst from a reconnecting session can cancel it.
582 if not self._in_use_by_queue and (
583 self._pending_play_media_task is None or self._pending_play_media_task.done()
584 ):
585 self._pending_play_media_task = self.mass.create_task(
586 self._deferred_play_media_fire()
587 )
588 elif event.type in (BackendEventType.PAUSED, BackendEventType.STOPPED):
589 self._playing = False
590 # A pause/stop is the definitive "don't start": cancel a deferred fire
591 # from a now-stale 'playing'. The active get_audio_stream sees the PCM
592 # stop and ends the stream (clean EOF), so the player leaves the playing
593 # state; the next 'playing' event re-fires play_media to resume.
594 self._cancel_pending_play_media()
595
596 if event.type is BackendEventType.METADATA and event.metadata is not None:
597 self._apply_metadata(event.metadata)
598 elif event.type is BackendEventType.POSITION and event.position is not None:
599 self._stream_metadata.elapsed_time = event.position
600 self._stream_metadata.elapsed_time_last_updated = int(time.time())
601
602 if event.type is BackendEventType.VOLUME and event.volume is not None:
603 await self._handle_volume_event(event.volume)
604
605 # push metadata update to the active queue item's streamdetails
606 if self._in_use_by_queue:
607 self.mass.streams.update_stream_metadata(
608 self._in_use_by_queue,
609 AUDIO_SOURCE_ID,
610 self.instance_id,
611 self._stream_metadata,
612 )
613
614 def _apply_metadata(self, metadata: BackendTrackMetadata) -> None:
615 """Update the live StreamMetadata from a normalized metadata event."""
616 self._stream_metadata.uri = metadata.track_uri
617 if metadata.title:
618 self._stream_metadata.title = metadata.title
619 self._stream_metadata.artist = metadata.artist
620 self._stream_metadata.album = metadata.album
621 self._stream_metadata.image_url = metadata.image_url
622 self._stream_metadata.description = None
623 self._stream_metadata.duration = metadata.duration
624 self._stream_metadata.elapsed_time = metadata.position
625 self._stream_metadata.elapsed_time_last_updated = int(time.time())
626
627 async def _handle_volume_event(self, volume: int) -> None:
628 """
629 Apply a Spotify-side volume change to the linked MA player.
630
631 :param volume: The reported volume as a 0-100 percentage.
632 """
633 # Ignore our own echo: the backend emits a 'volume' event for the value we
634 # just pushed in on_volume_change; re-applying it would ping-pong.
635 if volume == self._last_volume_sent:
636 return
637 # Ignore the volume the backend reports right after a session becomes
638 # active â the player's own volume should win in that window.
639 if time.time() - self._last_session_active_time < INITIAL_VOLUME_GRACE_S:
640 self.logger.debug("Ignoring initial volume_changed event after session active")
641 return
642 if not self._in_use_by_queue:
643 return
644 previous_volume = self._last_volume_sent
645 self._last_volume_sent = volume
646 try:
647 await self.mass.players.cmd_volume_set(self._in_use_by_queue, volume)
648 except Exception as err:
649 # Volume sync is best-effort: the player may not support volume, or the
650 # command may fail. Restore the cached value so a retry isn't wrongly
651 # deduped, and never let it bubble up and drop the events loop.
652 self._last_volume_sent = previous_volume
653 self.logger.debug("Could not set volume on %s: %s", self._in_use_by_queue, err)
654
655 async def _sync_player_volume_to_spotify(self, player_id: str) -> None:
656 """
657 Push a player's current volume to the backend (best-effort).
658
659 :param player_id: The MA player whose volume to push.
660 """
661 player = self.mass.players.get_player(player_id)
662 if player is None or player.state.volume_level is None:
663 return
664 # clamp: the logical volume can be out of range until volume limit
665 # enforcement runs
666 volume = max(0, min(100, player.state.volume_level))
667 # No dedupe against _last_volume_sent here: it holds the last value
668 # exchanged with the backend, not the backend's current volume, which
669 # resets to its 100% default on a new session or backend restart.
670 try:
671 await self._push_volume_to_backend(volume)
672 except Exception as err:
673 self.logger.debug("Failed to sync player volume to Spotify: %s", err)
674
675 async def _push_volume_to_backend(self, volume: int) -> None:
676 """
677 Send an absolute 0-100 volume to the backend.
678
679 :param volume: Volume percentage to send.
680 :raises Exception: If the request to the backend fails.
681 """
682 previous_volume = self._last_volume_sent
683 # Record BEFORE the call: the backend echoes a 'volume' event back, and
684 # that echo can arrive over the event stream while we're still awaiting
685 # set_volume. Recording up front lets _handle_volume_event dedupe it
686 # instead of bouncing it back as a player volume change.
687 self._last_volume_sent = volume
688 try:
689 await self._backend.set_volume(volume)
690 except Exception:
691 # restore on failure so a retry of this value isn't wrongly deduped
692 self._last_volume_sent = previous_volume
693 raise
694