Spotify connect: Fix sudden volume change when session starts (#2704)
authorMarvin Schenkel <marvinschenkel@gmail.com>
Mon, 1 Dec 2025 10:12:43 +0000 (11:12 +0100)
committerGitHub <noreply@github.com>
Mon, 1 Dec 2025 10:12:43 +0000 (11:12 +0100)
music_assistant/providers/spotify_connect/__init__.py

index c81c0fd4b1fdf2e4b78fd284363a484dc17f2660..db65911c85a1ed94afea46a28095f41eca574628 100644 (file)
@@ -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: