music-assistant-server

38.3 KBPY
__init__.py
38.3 KB861 lines • python
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