/
/
/
1"""
2Spotify Connect plugin for Music Assistant.
3
4We tie a single player to a single Spotify Connect daemon.
5The provider has multi instance support,
6so multiple players can be linked to multiple Spotify Connect daemons.
7"""
8
9from __future__ import annotations
10
11import asyncio
12import os
13import pathlib
14import time
15from collections.abc import Callable
16from contextlib import suppress
17from typing import TYPE_CHECKING, cast
18
19from aiohttp.web import Response
20from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
21from music_assistant_models.enums import (
22 ConfigEntryType,
23 ContentType,
24 EventType,
25 PlaybackState,
26 ProviderFeature,
27 ProviderType,
28 StreamType,
29)
30from music_assistant_models.errors import UnsupportedFeaturedException
31from music_assistant_models.media_items import AudioFormat
32from music_assistant_models.streamdetails import StreamMetadata
33
34from music_assistant.constants import CONF_ENTRY_WARN_PREVIEW
35from music_assistant.helpers.process import AsyncProcess, check_output
36from music_assistant.models.plugin import PluginProvider, PluginSource
37from music_assistant.providers.spotify.helpers import get_librespot_binary
38
39if TYPE_CHECKING:
40 from aiohttp.web import Request
41 from music_assistant_models.config_entries import ConfigValueType, ProviderConfig
42 from music_assistant_models.event import MassEvent
43 from music_assistant_models.provider import ProviderManifest
44
45 from music_assistant.mass import MusicAssistant
46 from music_assistant.models import ProviderInstanceType
47 from music_assistant.providers.spotify.provider import SpotifyProvider
48
49CONF_MASS_PLAYER_ID = "mass_player_id"
50CONF_HANDOFF_MODE = "handoff_mode"
51CONNECT_ITEM_ID = "spotify_connect"
52CONF_PUBLISH_NAME = "publish_name"
53CONF_ALLOW_PLAYER_SWITCH = "allow_player_switch"
54
55# Special value for auto player selection
56PLAYER_ID_AUTO = "__auto__"
57
58EVENTS_SCRIPT = pathlib.Path(__file__).parent.resolve().joinpath("events.py")
59
60SUPPORTED_FEATURES = {ProviderFeature.AUDIO_SOURCE}
61
62
63async def setup(
64 mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
65) -> ProviderInstanceType:
66 """Initialize provider(instance) with given configuration."""
67 return SpotifyConnectProvider(mass, manifest, config)
68
69
70async def get_config_entries(
71 mass: MusicAssistant,
72 instance_id: str | None = None, # noqa: ARG001
73 action: str | None = None, # noqa: ARG001
74 values: dict[str, ConfigValueType] | None = None, # noqa: ARG001
75) -> tuple[ConfigEntry, ...]:
76 """
77 Return Config entries to setup this provider.
78
79 instance_id: id of an existing provider instance (None if new instance setup).
80 action: [optional] action key called from config entries UI.
81 values: the (intermediate) raw values for config entries sent with the action.
82 """
83 return (
84 CONF_ENTRY_WARN_PREVIEW,
85 ConfigEntry(
86 key=CONF_MASS_PLAYER_ID,
87 type=ConfigEntryType.STRING,
88 label="Connected Music Assistant Player",
89 description="The Music Assistant player connected to this Spotify Connect plugin. "
90 "When you start playback in the Spotify app to this virtual speaker, "
91 "the audio will play on the selected player. "
92 "Set to 'Auto' to automatically select a currently playing player, "
93 "or the first available player if none is playing.",
94 multi_value=False,
95 default_value=PLAYER_ID_AUTO,
96 options=[
97 ConfigValueOption("Auto (prefer playing player)", PLAYER_ID_AUTO),
98 *(
99 ConfigValueOption(x.display_name, x.player_id)
100 for x in sorted(
101 mass.players.all_players(False, False), key=lambda p: p.display_name.lower()
102 )
103 ),
104 ],
105 required=True,
106 ),
107 ConfigEntry(
108 key=CONF_ALLOW_PLAYER_SWITCH,
109 type=ConfigEntryType.BOOLEAN,
110 label="Allow manual player switching",
111 description="When enabled, you can select this plugin as a source on any player "
112 "to switch playback to that player. When disabled, playback is fixed to the "
113 "configured default player.",
114 default_value=True,
115 ),
116 ConfigEntry(
117 key=CONF_PUBLISH_NAME,
118 type=ConfigEntryType.STRING,
119 label="Name to display in the Spotify app",
120 description="How should this Spotify Connect device be named in the Spotify app?",
121 default_value="Music Assistant",
122 ),
123 # ConfigEntry(
124 # key=CONF_HANDOFF_MODE,
125 # type=ConfigEntryType.BOOLEAN,
126 # label="Enable handoff mode",
127 # default_value=False,
128 # description="The default behavior of the Spotify Connect plugin is to "
129 # "forward the actual Spotify Connect audio stream as-is to the player. "
130 # "The Spotify audio is basically just a live audio stream. \n\n"
131 # "For controlling the playback (and queue contents), "
132 # "you need to use the Spotify app. Also, depending on the player's "
133 # "buffering strategy and capabilities, the audio may not be fully in sync with "
134 # "what is shown in the Spotify app. \n\n"
135 # "When enabling handoff mode, the Spotify Connect plugin will instead "
136 # "forward the Spotify playback request to the Music Assistant Queue, so basically "
137 # "the spotify app can be used to initiate playback, but then MA will take over "
138 # "the playback and manage the queue, which is the normal operating mode of MA. \n\n"
139 # "This mode however means that the Spotify app will not report the actual playback ",
140 # required=False,
141 # ),
142 )
143
144
145class SpotifyConnectProvider(PluginProvider):
146 """Implementation of a Spotify Connect Plugin."""
147
148 def __init__(
149 self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
150 ) -> None:
151 """Initialize MusicProvider."""
152 super().__init__(mass, manifest, config, SUPPORTED_FEATURES)
153 # Default player ID from config (PLAYER_ID_AUTO or a specific player_id)
154 self._default_player_id: str = (
155 cast("str", self.config.get_value(CONF_MASS_PLAYER_ID)) or PLAYER_ID_AUTO
156 )
157 # Whether manual player switching is allowed (default to True for upgrades)
158 allow_switch_value = self.config.get_value(CONF_ALLOW_PLAYER_SWITCH)
159 self._allow_player_switch: bool = (
160 cast("bool", allow_switch_value) if allow_switch_value is not None else True
161 )
162 # Currently active player (the one currently playing or selected)
163 self._active_player_id: str | None = None
164 self.cache_dir = os.path.join(self.mass.cache_path, self.instance_id)
165 self._librespot_bin: str | None = None
166 self._stop_called: bool = False
167 self._runner_task: asyncio.Task | None = None # type: ignore[type-arg]
168 self._librespot_proc: AsyncProcess | None = None
169 self._librespot_started = asyncio.Event()
170 self.named_pipe = f"/tmp/{self.instance_id}" # noqa: S108
171 connect_name = cast("str", self.config.get_value(CONF_PUBLISH_NAME)) or self.name
172 self.logger.debug(
173 "Init plugin with name '%s' for player '%s' with instance id '%s'",
174 self.name,
175 self._default_player_id,
176 self.instance_id,
177 )
178 self._source_details = PluginSource(
179 id=self.instance_id,
180 name=self.name,
181 # passive=False allows this source to be selected on any player
182 # Only show in source list if player switching is allowed
183 passive=not self._allow_player_switch,
184 # Playback control capabilities will be enabled when Spotify Web API is available
185 can_play_pause=False,
186 can_seek=False,
187 can_next_previous=False,
188 audio_format=AudioFormat(
189 content_type=ContentType.PCM_S16LE,
190 codec_type=ContentType.PCM_S16LE,
191 sample_rate=44100,
192 bit_depth=16,
193 channels=2,
194 ),
195 metadata=StreamMetadata(
196 title=f"Spotify Connect | {connect_name}",
197 ),
198 stream_type=StreamType.NAMED_PIPE,
199 path=self.named_pipe,
200 )
201 # Set the on_select callback for when the source is selected on a player
202 self._source_details.on_select = self._on_source_selected
203 self._audio_buffer: asyncio.Queue[bytes] = asyncio.Queue(10)
204 # Web API integration for playback control
205 self._connected_spotify_username: str | None = None
206 self._spotify_provider: SpotifyProvider | None = None
207 self._on_unload_callbacks: list[Callable[..., None]] = []
208 self._runner_error_count = 0
209 self._spotify_device_id: str | None = None
210 self._last_session_connected_time: float = 0
211 self._last_volume_sent_to_spotify: int | None = None
212
213 async def handle_async_init(self) -> None:
214 """Handle async initialization of the provider."""
215 self._librespot_bin = await get_librespot_binary()
216 # Always start the daemon - we always have a default player configured
217 self._setup_player_daemon()
218
219 # Subscribe to events
220 self._on_unload_callbacks.append(
221 self.mass.subscribe(
222 self._on_provider_event,
223 (EventType.PROVIDERS_UPDATED),
224 )
225 )
226 self._on_unload_callbacks.append(
227 self.mass.streams.register_dynamic_route(
228 f"/{self.instance_id}",
229 self._handle_custom_webservice,
230 )
231 )
232
233 async def unload(self, is_removed: bool = False) -> None:
234 """Handle close/cleanup of the provider."""
235 self._stop_called = True
236 if self._runner_task and not self._runner_task.done():
237 self._runner_task.cancel()
238 with suppress(asyncio.CancelledError):
239 await self._runner_task
240 for callback in self._on_unload_callbacks:
241 callback()
242
243 def get_source(self) -> PluginSource:
244 """Get (audio)source details for this plugin."""
245 return self._source_details
246
247 @property
248 def active_player_id(self) -> str | None:
249 """Return the currently active player ID for this plugin."""
250 return self._active_player_id
251
252 def _get_target_player_id(self) -> str | None:
253 """
254 Determine the target player ID for playback.
255
256 Returns the player ID to use based on the following priority:
257 1. If a player was explicitly selected (source selected on a player), use that
258 2. If default is 'auto': prefer playing player, then first available
259 3. If a specific default player is configured, use that
260
261 :return: The player ID to use for playback, or None if no player available.
262 """
263 # If there's an active player (source was selected on a player), use it
264 if self._active_player_id:
265 # Validate that the active player still exists
266 if self.mass.players.get_player(self._active_player_id):
267 return self._active_player_id
268 # Active player no longer exists, clear it
269 self._active_player_id = None
270
271 # Handle auto selection
272 if self._default_player_id == PLAYER_ID_AUTO:
273 all_players = list(self.mass.players.all_players(False, False))
274 # First, try to find a playing player
275 for player in all_players:
276 if player.state.playback_state == PlaybackState.PLAYING:
277 self.logger.debug("Auto-selecting playing player: %s", player.display_name)
278 return player.player_id
279 # Fallback to first available player
280 if all_players:
281 first_player = all_players[0]
282 self.logger.debug(
283 "Auto-selecting first available player: %s", first_player.display_name
284 )
285 return first_player.player_id
286 # No player available
287 return None
288
289 # Use the specific default player if configured and it still exists
290 if self.mass.players.get_player(self._default_player_id):
291 return self._default_player_id
292 self.logger.warning(
293 "Configured default player '%s' no longer exists", self._default_player_id
294 )
295 return None
296
297 async def _on_source_selected(self) -> None:
298 """
299 Handle callback when this source is selected on a player.
300
301 This is called by the player controller when a user selects this
302 plugin as a source on a specific player.
303 """
304 # The player that selected us is stored in in_use_by by the player controller
305 new_player_id = self._source_details.in_use_by
306 if not new_player_id:
307 return
308
309 # Check if manual player switching is allowed
310 if not self._allow_player_switch:
311 # Player switching disabled - only allow if it matches the current target
312 current_target = self._get_target_player_id()
313 if new_player_id != current_target:
314 self.logger.debug(
315 "Manual player switching disabled, ignoring selection on %s",
316 new_player_id,
317 )
318 # Revert in_use_by to reflect the rejection
319 self._source_details.in_use_by = current_target
320 self.mass.players.trigger_player_update(new_player_id)
321 return
322
323 # If there's already an active player and it's different, kick it out
324 if self._active_player_id and self._active_player_id != new_player_id:
325 self.logger.info(
326 "Source selected on player %s, stopping playback on %s",
327 new_player_id,
328 self._active_player_id,
329 )
330 # Stop the current player
331 try:
332 await self.mass.players.cmd_stop(self._active_player_id)
333 except Exception as err:
334 self.logger.debug(
335 "Failed to stop previous player %s: %s", self._active_player_id, err
336 )
337
338 # Update the active player
339 self._active_player_id = new_player_id
340 self.logger.debug("Active player set to: %s", new_player_id)
341
342 # Only persist the selected player as the new default if not in auto mode
343 if self._default_player_id != PLAYER_ID_AUTO:
344 self._save_last_player_id(new_player_id)
345
346 def _clear_active_player(self) -> None:
347 """
348 Clear the active player and revert to default if configured.
349
350 Called when playback ends to reset the plugin state.
351 """
352 prev_player_id = self._active_player_id
353 self._active_player_id = None
354 self._source_details.in_use_by = None
355
356 if prev_player_id:
357 self.logger.debug("Playback ended on player %s, clearing active player", prev_player_id)
358 # Trigger update for the player that was using this source
359 self.mass.players.trigger_player_update(prev_player_id)
360
361 def _save_last_player_id(self, player_id: str) -> None:
362 """Persist the selected player ID to config as the new default."""
363 if self._default_player_id == player_id:
364 return # No change needed
365 try:
366 self.mass.config.set_raw_provider_config_value(
367 self.instance_id, CONF_MASS_PLAYER_ID, player_id
368 )
369 self._default_player_id = player_id
370 except Exception as err:
371 self.logger.debug("Failed to persist player ID: %s", err)
372
373 async def _check_spotify_provider_match(self) -> None:
374 """Check if a Spotify music provider is available with matching username."""
375 # Username must be available (set from librespot output)
376 if not self._connected_spotify_username:
377 return
378
379 # Look for a Spotify music provider with matching username
380 for provider in self.mass.get_providers():
381 if provider.domain == "spotify" and provider.type == ProviderType.MUSIC:
382 # Check if the username matches
383 if hasattr(provider, "_sp_user") and provider._sp_user:
384 spotify_username = provider._sp_user.get("id")
385 if spotify_username == self._connected_spotify_username:
386 self.logger.debug(
387 "Found matching Spotify music provider - "
388 "enabling playback control via Web API"
389 )
390 self._spotify_provider = cast("SpotifyProvider", provider)
391 self._update_source_capabilities()
392 return
393
394 # No matching provider found
395 if self._spotify_provider is not None:
396 self.logger.debug(
397 "Spotify music provider no longer available - disabling playback control"
398 )
399 self._spotify_provider = None
400 self._update_source_capabilities()
401
402 def _update_source_capabilities(self) -> None:
403 """Update source capabilities based on Web API availability."""
404 has_web_api = self._spotify_provider is not None
405 self._source_details.can_play_pause = has_web_api
406 self._source_details.can_seek = has_web_api
407 self._source_details.can_next_previous = has_web_api
408
409 # Register or unregister callbacks based on availability
410 if has_web_api:
411 self._source_details.on_play = self._on_play
412 self._source_details.on_pause = self._on_pause
413 self._source_details.on_next = self._on_next
414 self._source_details.on_previous = self._on_previous
415 self._source_details.on_seek = self._on_seek
416 self._source_details.on_volume = self._on_volume
417 else:
418 self._source_details.on_play = None
419 self._source_details.on_pause = None
420 self._source_details.on_next = None
421 self._source_details.on_previous = None
422 self._source_details.on_seek = None
423 self._source_details.on_volume = None
424
425 # Trigger player update to reflect capability changes
426 if self._source_details.in_use_by:
427 self.mass.players.trigger_player_update(self._source_details.in_use_by)
428
429 async def _on_play(self) -> None:
430 """Handle play command via Spotify Web API."""
431 if not self._spotify_provider:
432 raise UnsupportedFeaturedException(
433 "Playback control requires a matching Spotify music provider"
434 )
435 try:
436 # First try to transfer playback to this device if needed
437 await self._ensure_active_device()
438 await self._spotify_provider._put_data("me/player/play")
439 except Exception as err:
440 self.logger.warning("Failed to send play command via Spotify Web API: %s", err)
441 raise
442
443 async def _on_pause(self) -> None:
444 """Handle pause command via Spotify Web API."""
445 if not self._spotify_provider:
446 raise UnsupportedFeaturedException(
447 "Playback control requires a matching Spotify music provider"
448 )
449 try:
450 await self._spotify_provider._put_data("me/player/pause")
451 except Exception as err:
452 self.logger.warning("Failed to send pause command via Spotify Web API: %s", err)
453 raise
454
455 async def _on_next(self) -> None:
456 """Handle next track command via Spotify Web API."""
457 if not self._spotify_provider:
458 raise UnsupportedFeaturedException(
459 "Playback control requires a matching Spotify music provider"
460 )
461 try:
462 await self._spotify_provider._post_data("me/player/next", want_result=False)
463 except Exception as err:
464 self.logger.warning("Failed to send next track command via Spotify Web API: %s", err)
465 raise
466
467 async def _on_previous(self) -> None:
468 """Handle previous track command via Spotify Web API."""
469 if not self._spotify_provider:
470 raise UnsupportedFeaturedException(
471 "Playback control requires a matching Spotify music provider"
472 )
473 try:
474 await self._spotify_provider._post_data("me/player/previous")
475 except Exception as err:
476 self.logger.warning("Failed to send previous command via Spotify Web API: %s", err)
477 raise
478
479 async def _on_seek(self, position: int) -> None:
480 """Handle seek command via Spotify Web API."""
481 if not self._spotify_provider:
482 raise UnsupportedFeaturedException(
483 "Playback control requires a matching Spotify music provider"
484 )
485 try:
486 # Spotify Web API expects position in milliseconds
487 position_ms = position * 1000
488 await self._spotify_provider._put_data(f"me/player/seek?position_ms={position_ms}")
489 except Exception as err:
490 self.logger.warning("Failed to send seek command via Spotify Web API: %s", err)
491 raise
492
493 async def _on_volume(self, volume: int) -> None:
494 """Handle volume change command via Spotify Web API.
495
496 :param volume: Volume level (0-100) from Music Assistant.
497 """
498 if not self._spotify_provider:
499 raise UnsupportedFeaturedException(
500 "Volume control requires a matching Spotify music provider"
501 )
502
503 # Prevent ping-pong: only send if volume actually changed from what we last sent
504 if self._last_volume_sent_to_spotify == volume:
505 self.logger.debug("Skipping volume update to Spotify - already at %d%%", volume)
506 return
507
508 try:
509 # Bypass throttler for volume changes to ensure responsive UI
510 async with self._spotify_provider.throttler.bypass():
511 await self._spotify_provider._put_data(f"me/player/volume?volume_percent={volume}")
512 self._last_volume_sent_to_spotify = volume
513 except Exception as err:
514 self.logger.warning("Failed to send volume command via Spotify Web API: %s", err)
515 raise
516
517 async def _get_spotify_device_id(self) -> str | None:
518 """Get the Spotify Connect device ID for this instance.
519
520 :return: Device ID if found, None otherwise.
521 """
522 if not self._spotify_provider:
523 return None
524
525 try:
526 # Get list of available devices from Spotify Web API
527 devices_data = await self._spotify_provider._get_data("me/player/devices")
528 devices = devices_data.get("devices", [])
529
530 # Look for our device by name
531 connect_name = cast("str", self.config.get_value(CONF_PUBLISH_NAME)) or self.name
532 for device in devices:
533 if device.get("name") == connect_name and device.get("type") == "Speaker":
534 device_id: str | None = device.get("id")
535 self.logger.debug("Found Spotify Connect device ID: %s", device_id)
536 return device_id
537
538 self.logger.debug(
539 "Could not find Spotify Connect device '%s' in available devices", connect_name
540 )
541 return None
542 except Exception as err:
543 self.logger.debug("Failed to get Spotify devices: %s", err)
544 return None
545
546 async def _ensure_active_device(self) -> None:
547 """
548 Ensure this Spotify Connect device is the active player on Spotify.
549
550 Transfers playback to this device if it's not already active.
551 """
552 if not self._spotify_provider:
553 return
554
555 try:
556 # Get current playback state
557 try:
558 playback_data = await self._spotify_provider._get_data("me/player")
559 current_device = playback_data.get("device", {}) if playback_data else {}
560 current_device_id = current_device.get("id")
561 except Exception as err:
562 if getattr(err, "status", None) == 204:
563 # No active device
564 current_device_id = None
565 else:
566 raise
567
568 # Get our device ID if we don't have it cached
569 if not self._spotify_device_id:
570 self._spotify_device_id = await self._get_spotify_device_id()
571
572 # If we couldn't find our device ID, we can't transfer
573 if not self._spotify_device_id:
574 self.logger.debug("Cannot transfer playback - device ID not found")
575 return
576
577 # Check if we're already the active device
578 if current_device_id == self._spotify_device_id:
579 self.logger.debug("Already the active Spotify device")
580 return
581
582 # Transfer playback to this device
583 self.logger.info("Transferring Spotify playback to this device")
584 await self._spotify_provider._put_data(
585 "me/player",
586 data={"device_ids": [self._spotify_device_id], "play": False},
587 )
588 except Exception as err:
589 self.logger.debug("Failed to ensure active device: %s", err)
590 # Don't raise - this is a best-effort operation
591
592 def _on_provider_event(self, event: MassEvent) -> None:
593 """Handle provider added/removed events to check for Spotify provider."""
594 # Re-check for matching Spotify provider when providers change
595 if self._connected_spotify_username:
596 self.mass.create_task(self._check_spotify_provider_match())
597
598 def _process_librespot_stderr_line(self, line: str) -> None:
599 """
600 Process a single line from librespot stderr output.
601
602 :param line: A line from librespot's stderr output.
603 """
604 if (
605 not self._librespot_started.is_set()
606 and "Using StdoutSink (pipe) with format: S16" in line
607 ):
608 self._librespot_started.set()
609 if "error sending packet Os" in line:
610 return
611 if "dropping truncated packet" in line:
612 return
613 if "couldn't parse packet from " in line:
614 return
615 if "Authenticated as '" in line:
616 # Extract username from librespot authentication message
617 # Format: "Authenticated as 'username'"
618 try:
619 parts = line.split("Authenticated as '")
620 if len(parts) > 1:
621 username_part = parts[1].split("'")
622 if len(username_part) > 0 and username_part[0]:
623 username = username_part[0]
624 self._connected_spotify_username = username
625 self.logger.debug("Authenticated to Spotify as: %s", username)
626 # Check for provider match now that we have the username
627 self.mass.create_task(self._check_spotify_provider_match())
628 else:
629 self.logger.warning("Could not parse Spotify username from line: %s", line)
630 else:
631 self.logger.warning("Could not parse Spotify username from line: %s", line)
632 except Exception as err:
633 self.logger.warning("Error parsing Spotify username from line: %s - %s", line, err)
634 return
635 self.logger.debug("[%s] %s", self.name, line)
636
637 async def _librespot_runner(self) -> None:
638 """Run the spotify connect daemon in a background task."""
639 assert self._librespot_bin
640 self.logger.info("Starting Spotify Connect background daemon [%s]", self.name)
641 env = {"MASS_CALLBACK": f"{self.mass.streams.base_url}/{self.instance_id}"}
642 await check_output("rm", "-f", self.named_pipe)
643 await asyncio.sleep(0.1)
644 await check_output("mkfifo", self.named_pipe)
645 await asyncio.sleep(0.1)
646 try:
647 # Get initial volume from default player if available, or use 20 as fallback
648 initial_volume = 20
649 if self._default_player_id and self._default_player_id != PLAYER_ID_AUTO:
650 if _player := self.mass.players.get_player(self._default_player_id):
651 if _player.volume_level:
652 initial_volume = _player.volume_level
653 args: list[str] = [
654 self._librespot_bin,
655 "--name",
656 cast("str", self.config.get_value(CONF_PUBLISH_NAME)) or self.name,
657 "--cache",
658 self.cache_dir,
659 "--disable-audio-cache",
660 "--bitrate",
661 "320",
662 "--backend",
663 "pipe",
664 "--device",
665 self.named_pipe,
666 "--dither",
667 "none",
668 # disable volume control
669 "--mixer",
670 "passthrough",
671 "--volume-ctrl",
672 "passthrough",
673 "--initial-volume",
674 str(initial_volume),
675 "--enable-volume-normalisation",
676 # forward events to the events script
677 "--onevent",
678 str(EVENTS_SCRIPT),
679 "--emit-sink-events",
680 ]
681 bind_ip = self.mass.streams.bind_ip
682 if bind_ip and bind_ip != "0.0.0.0":
683 args.extend(["--zeroconf-interface", bind_ip])
684 self._librespot_proc = librespot = AsyncProcess(
685 args, stdout=False, stderr=True, name=f"librespot[{self.name}]", env=env
686 )
687 await librespot.start()
688
689 # keep reading logging from stderr until exit
690 async for line in librespot.iter_stderr():
691 self._process_librespot_stderr_line(line)
692 finally:
693 await librespot.close()
694 self.logger.info("Spotify Connect background daemon stopped for %s", self.name)
695 await check_output("rm", "-f", self.named_pipe)
696 if not self._librespot_started.is_set():
697 self.unload_with_error("Unable to initialize librespot daemon.")
698 # auto restart if not stopped manually
699 elif not self._stop_called and self._runner_error_count >= 5:
700 self.unload_with_error("Librespot daemon failed to start multiple times.")
701 elif not self._stop_called:
702 self._runner_error_count += 1
703 self.mass.call_later(2, self._setup_player_daemon)
704
705 def _setup_player_daemon(self) -> None:
706 """Handle setup of the spotify connect daemon for a player."""
707 self._librespot_started.clear()
708 self._runner_task = self.mass.create_task(self._librespot_runner())
709
710 async def _handle_custom_webservice(self, request: Request) -> Response: # noqa: PLR0915
711 """Handle incoming requests on the custom webservice."""
712 json_data = await request.json()
713 self.logger.debug("Received metadata on webservice [%s]: \n%s", self.name, json_data)
714
715 event_name = json_data.get("event")
716
717 # handle session connected event
718 # extract the connected username and check for matching Spotify provider
719 if event_name == "session_connected":
720 # Track when session connected for volume event filtering
721 self._last_session_connected_time = time.time()
722 username = json_data.get("user_name")
723 self.logger.debug(
724 "Session connected event - username from event: %s, current username: %s",
725 username,
726 self._connected_spotify_username,
727 )
728 if username and username != self._connected_spotify_username:
729 self.logger.info("Spotify Connect session connected for user: %s", username)
730 self._connected_spotify_username = username
731 await self._check_spotify_provider_match()
732 elif not username:
733 self.logger.warning("Session connected event received but no username in payload")
734
735 # handle session disconnected event
736 if event_name == "session_disconnected":
737 self.logger.info("Spotify Connect session disconnected")
738 self._connected_spotify_username = None
739 if self._spotify_provider is not None:
740 self._spotify_provider = None
741 self._update_source_capabilities()
742 # Clear active player and potentially stop daemon on session disconnect
743 self._clear_active_player()
744
745 # handle paused event - clear in_use_by so UI shows correct active source
746 # this happens when MA starts playing while Spotify Connect was active
747 # Note: we don't call _clear_active_player here because pause is temporary
748 # and we want to resume on the same player when playback resumes
749 if event_name == "paused" and self._source_details.in_use_by:
750 current_player = self._source_details.in_use_by
751 self.logger.debug(
752 "Spotify Connect paused, releasing player UI state for %s", current_player
753 )
754 self._source_details.in_use_by = None
755 self.mass.players.trigger_player_update(current_player)
756
757 # handle session connected event
758 # this player has become the active spotify connect player
759 # we need to start the playback
760 if event_name in ("sink", "playing") and (not self._source_details.in_use_by):
761 # If we receive a 'sink' event but we are not officially connected
762 # (i.e. we just disconnected), ignore it to prevent accidental
763 # re-activation of this player (trailing event from dying session).
764 if event_name == "sink" and not self._connected_spotify_username:
765 self.logger.debug("Ignoring trailing sink event while disconnected")
766 return Response()
767
768 # Check for matching Spotify provider now that playback is starting
769 # This ensures the Spotify music provider has had time to initialize
770 if not self._connected_spotify_username or not self._spotify_provider:
771 await self._check_spotify_provider_match()
772
773 # Make this device the active Spotify player via Web API
774 if self._spotify_provider:
775 self.mass.create_task(self._ensure_active_device())
776
777 # Determine target player for playback
778 target_player_id = self._get_target_player_id()
779 if target_player_id:
780 # initiate playback by selecting this source on the target player
781 self.logger.info(
782 "Starting Spotify Connect playback [%s] on player %s",
783 self.instance_id,
784 target_player_id,
785 )
786 self._active_player_id = target_player_id
787 self.mass.create_task(
788 self.mass.players.select_source(target_player_id, self.instance_id)
789 )
790 self._source_details.in_use_by = target_player_id
791 else:
792 self.logger.warning(
793 "Spotify Connect playback started but no player available. "
794 "Select this source on a player to start playback."
795 )
796
797 # parse metadata fields
798 if common_meta := json_data.get("common_metadata_fields", {}):
799 uri = common_meta.get("uri", "Unknown")
800 title = common_meta.get("name", "Unknown")
801 image_url = images[0] if (images := common_meta.get("covers")) else None
802 if self._source_details.metadata is None:
803 self._source_details.metadata = StreamMetadata(uri=uri, title=title)
804 self._source_details.metadata.uri = uri
805 self._source_details.metadata.title = title
806 self._source_details.metadata.artist = None
807 self._source_details.metadata.album = None
808 self._source_details.metadata.image_url = image_url
809 self._source_details.metadata.description = None
810 duration_ms = common_meta.get("duration_ms", 0)
811 self._source_details.metadata.duration = (
812 int(duration_ms) // 1000 if duration_ms is not None else None
813 )
814 # Reset elapsed time when track changes to prevent showing stale elapsed time
815 # from previous track
816 self._source_details.metadata.elapsed_time = 0
817 self._source_details.metadata.elapsed_time_last_updated = int(time.time())
818
819 if track_meta := json_data.get("track_metadata_fields", {}):
820 if artists := track_meta.get("artists"):
821 if self._source_details.metadata is not None:
822 self._source_details.metadata.artist = artists[0]
823 if self._source_details.metadata is not None:
824 self._source_details.metadata.album = track_meta.get("album")
825
826 if episode_meta := json_data.get("episode_metadata_fields", {}):
827 if self._source_details.metadata is not None:
828 self._source_details.metadata.description = episode_meta.get("description")
829
830 if "position_ms" in json_data:
831 if self._source_details.metadata is not None:
832 self._source_details.metadata.elapsed_time = int(json_data["position_ms"]) // 1000
833 self._source_details.metadata.elapsed_time_last_updated = int(time.time())
834
835 if event_name == "volume_changed" and (volume := json_data.get("volume")):
836 # Ignore volume_changed events that fire immediately after session_connect
837 # We want to use the volume from MA in that case
838 time_since_connect = time.time() - self._last_session_connected_time
839 if time_since_connect < 3.0:
840 self.logger.debug(
841 "Ignoring initial volume_changed event (%.2fs after session_connect)",
842 time_since_connect,
843 )
844 elif self._source_details.in_use_by:
845 # Spotify Connect volume is 0-65535
846 volume = int(int(volume) / 65535 * 100)
847 self._last_volume_sent_to_spotify = volume
848 try:
849 await self.mass.players.cmd_volume_set(self._source_details.in_use_by, volume)
850 except UnsupportedFeaturedException:
851 self.logger.debug(
852 "Player %s does not support volume control",
853 self._source_details.in_use_by,
854 )
855
856 # signal update to connected player
857 if self._source_details.in_use_by:
858 self.mass.players.trigger_player_update(self._source_details.in_use_by)
859
860 return Response()
861