From e140f2be905fbcdf4da07f60d2aab06a96a9fdff Mon Sep 17 00:00:00 2001 From: Marvin Schenkel Date: Mon, 1 Dec 2025 11:12:43 +0100 Subject: [PATCH] Spotify connect: Fix sudden volume change when session starts (#2704) --- .../providers/spotify_connect/__init__.py | 117 +++++++++++------- 1 file changed, 69 insertions(+), 48 deletions(-) diff --git a/music_assistant/providers/spotify_connect/__init__.py b/music_assistant/providers/spotify_connect/__init__.py index c81c0fd4..db65911c 100644 --- a/music_assistant/providers/spotify_connect/__init__.py +++ b/music_assistant/providers/spotify_connect/__init__.py @@ -168,12 +168,12 @@ class SpotifyConnectProvider(PluginProvider): self._on_unload_callbacks: list[Callable[..., None]] = [] self._runner_error_count = 0 self._spotify_device_id: str | None = None + self._last_session_connected_time: float = 0 async def handle_async_init(self) -> None: """Handle async initialization of the provider.""" self._librespot_bin = await get_librespot_binary() - self.player = self.mass.players.get(self.mass_player_id) - if self.player: + if self.mass.players.get(self.mass_player_id): self._setup_player_daemon() # Subscribe to events @@ -414,6 +414,45 @@ class SpotifyConnectProvider(PluginProvider): if self._connected_spotify_username: self.mass.create_task(self._check_spotify_provider_match()) + def _process_librespot_stderr_line(self, line: str) -> None: + """ + Process a single line from librespot stderr output. + + :param line: A line from librespot's stderr output. + """ + if ( + not self._librespot_started.is_set() + and "Using StdoutSink (pipe) with format: S16" in line + ): + self._librespot_started.set() + if "error sending packet Os" in line: + return + if "dropping truncated packet" in line: + return + if "couldn't parse packet from " in line: + return + if "Authenticated as '" in line: + # Extract username from librespot authentication message + # Format: "Authenticated as 'username'" + try: + parts = line.split("Authenticated as '") + if len(parts) > 1: + username_part = parts[1].split("'") + if len(username_part) > 0 and username_part[0]: + username = username_part[0] + self._connected_spotify_username = username + self.logger.debug("Authenticated to Spotify as: %s", username) + # Check for provider match now that we have the username + self.mass.create_task(self._check_spotify_provider_match()) + else: + self.logger.warning("Could not parse Spotify username from line: %s", line) + else: + self.logger.warning("Could not parse Spotify username from line: %s", line) + except Exception as err: + self.logger.warning("Error parsing Spotify username from line: %s - %s", line, err) + return + self.logger.debug(line) + async def _librespot_runner(self) -> None: """Run the spotify connect daemon in a background task.""" assert self._librespot_bin @@ -424,6 +463,12 @@ class SpotifyConnectProvider(PluginProvider): await check_output("mkfifo", self.named_pipe) await asyncio.sleep(0.1) try: + # Get initial volume from player, or use 20 as fallback + initial_volume = 20 + _player = self.mass.players.get(self.mass_player_id) + if _player and _player.volume_level: + initial_volume = _player.volume_level + args: list[str] = [ self._librespot_bin, "--name", @@ -445,7 +490,7 @@ class SpotifyConnectProvider(PluginProvider): "--volume-ctrl", "fixed", "--initial-volume", - f"{self.player.volume_level if self.player and self.player.volume_level else 100}", + str(initial_volume), "--enable-volume-normalisation", # forward events to the events script "--onevent", @@ -459,44 +504,7 @@ class SpotifyConnectProvider(PluginProvider): # keep reading logging from stderr until exit async for line in librespot.iter_stderr(): - if ( - not self._librespot_started.is_set() - and "Using StdoutSink (pipe) with format: S16" in line - ): - self._librespot_started.set() - if "error sending packet Os" in line: - continue - if "dropping truncated packet" in line: - continue - if "couldn't parse packet from " in line: - continue - if "Authenticated as '" in line: - # Extract username from librespot authentication message - # Format: "Authenticated as 'username'" - try: - parts = line.split("Authenticated as '") - if len(parts) > 1: - username_part = parts[1].split("'") - if len(username_part) > 0 and username_part[0]: - username = username_part[0] - self._connected_spotify_username = username - self.logger.debug("Authenticated to Spotify as: %s", username) - # Check for provider match now that we have the username - self.mass.create_task(self._check_spotify_provider_match()) - else: - self.logger.warning( - "Could not parse Spotify username from line: %s", line - ) - else: - self.logger.warning( - "Could not parse Spotify username from line: %s", line - ) - except Exception as err: - self.logger.warning( - "Error parsing Spotify username from line: %s - %s", line, err - ) - continue - self.logger.debug(line) + self._process_librespot_stderr_line(line) finally: await librespot.close() self.logger.info("Spotify Connect background daemon stopped for %s", self.name) @@ -516,7 +524,7 @@ class SpotifyConnectProvider(PluginProvider): self._runner_task = self.mass.create_task(self._librespot_runner()) def _on_mass_player_event(self, event: MassEvent) -> None: - """Handle incoming event from linked airplay player.""" + """Handle incoming event from linked player.""" if event.object_id != self.mass_player_id: return if event.event == EventType.PLAYER_REMOVED: @@ -537,6 +545,8 @@ class SpotifyConnectProvider(PluginProvider): # handle session connected event # extract the connected username and check for matching Spotify provider if event_name == "session_connected": + # Track when session connected for volume event filtering + self._last_session_connected_time = time.time() username = json_data.get("user_name") self.logger.debug( "Session connected event - username from event: %s, current username: %s", @@ -619,12 +629,23 @@ class SpotifyConnectProvider(PluginProvider): self._source_details.metadata.elapsed_time_last_updated = int(time.time()) if event_name == "volume_changed" and (volume := json_data.get("volume")): - # Spotify Connect volume is 0-65535 - volume = int(int(volume) / 65535 * 100) - try: - await self.mass.players.cmd_volume_set(self.mass_player_id, volume) - except UnsupportedFeaturedException: - self.logger.debug(f"Player {self.mass_player_id} does not support volume control") + # Ignore volume_changed events that fire immediately after session_connect + # We want to use the volume from MA in that case + time_since_connect = time.time() - self._last_session_connected_time + if time_since_connect < 2.0: + self.logger.debug( + "Ignoring initial volume_changed event (%.2fs after session_connect)", + time_since_connect, + ) + else: + # Spotify Connect volume is 0-65535 + volume = int(int(volume) / 65535 * 100) + try: + await self.mass.players.cmd_volume_set(self.mass_player_id, volume) + except UnsupportedFeaturedException: + self.logger.debug( + "Player %s does not support volume control", self.mass_player_id + ) # signal update to connected player if self._source_details.in_use_by: -- 2.34.1