Fix: AirPlay close stream the right way
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 16 May 2025 22:12:07 +0000 (00:12 +0200)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 16 May 2025 22:12:07 +0000 (00:12 +0200)
music_assistant/providers/airplay/player.py
music_assistant/providers/airplay/provider.py
music_assistant/providers/airplay/raop.py

index 84ea4ef1a36273f6aa2fbf2d177091cb3f7993a6..110532e321fb62184c41c6d186298ce47ec2d550 100644 (file)
@@ -5,8 +5,6 @@ from __future__ import annotations
 import asyncio
 from typing import TYPE_CHECKING
 
-from music_assistant_models.enums import PlayerState
-
 if TYPE_CHECKING:
     from zeroconf.asyncio import AsyncServiceInfo
 
@@ -31,14 +29,11 @@ class AirPlayPlayer:
         self.last_command_sent = 0.0
         self._lock = asyncio.Lock()
 
-    async def cmd_stop(self, update_state: bool = True) -> None:
+    async def cmd_stop(self) -> None:
         """Send STOP command to player."""
-        if self.raop_stream:
+        if self.raop_stream and self.raop_stream.session:
             # forward stop to the entire stream session
             await self.raop_stream.session.stop()
-        if update_state and (mass_player := self.mass.players.get(self.player_id)):
-            mass_player.state = PlayerState.IDLE
-            self.mass.players.update(mass_player.player_id)
 
     async def cmd_play(self) -> None:
         """Send PLAY (unpause) command to player."""
index f683895f822bdc09fe98f8e9972d63418e9c068e..378b10bc83940411b2da7aae0c9a10982c226a58 100644 (file)
@@ -365,7 +365,7 @@ class AirPlayProvider(PlayerProvider):
             # check if we need to replace the stream
             if airplay_player.raop_stream.prevent_playback:
                 # player is in prevent playback mode, we need to stop the stream
-                await airplay_player.cmd_stop(False)
+                await airplay_player.cmd_stop()
             else:
                 await airplay_player.raop_stream.session.replace_stream(audio_source)
                 return
@@ -661,9 +661,13 @@ class AirPlayProvider(PlayerProvider):
             elif "device-prevent-playback=1" in path:
                 # device switched to another source (or is powered off)
                 if raop_stream := airplay_player.raop_stream:
-                    self.mass.create_task(
-                        airplay_player.raop_stream.session.remove_client(airplay_player)
-                    )
+                    raop_stream.prevent_playback = True
+                    if mass_player.synced_to:
+                        self.mass.create_task(self.cmd_ungroup(airplay_player.player_id))
+                    else:
+                        self.mass.create_task(
+                            airplay_player.raop_stream.session.remove_client(airplay_player)
+                        )
             elif "device-prevent-playback=0" in path:
                 # device reports that its ready for playback again
                 if raop_stream := airplay_player.raop_stream:
index e71a4be4f75e540e16e567bb82fd0b5a75f91dee..195ea31f066e0e9585e1bb5c5825377d88651512 100644 (file)
@@ -53,7 +53,7 @@ class RaopStreamSession:
         self.prov = airplay_provider
         self.mass = airplay_provider.mass
         self.input_format = input_format
-        self._sync_clients = sync_clients
+        self.sync_clients = sync_clients
         self._audio_source = audio_source
         self._audio_source_task: asyncio.Task[None] | None = None
         self._lock = asyncio.Lock()
@@ -66,7 +66,7 @@ class RaopStreamSession:
         assert self.prov.cliraop_bin
         _, stdout = await check_output(self.prov.cliraop_bin, "-ntp")
         start_ntp = int(stdout.strip())
-        wait_start = 1750 + (250 * len(self._sync_clients))
+        wait_start = 1750 + (250 * len(self.sync_clients))
 
         async def _start_client(raop_player: AirPlayPlayer) -> None:
             # stop existing stream if running
@@ -77,7 +77,7 @@ class RaopStreamSession:
             await raop_player.raop_stream.start(start_ntp, wait_start)
 
         async with TaskManager(self.mass) as tm:
-            for _raop_player in self._sync_clients:
+            for _raop_player in self.sync_clients:
                 tm.create_task(_start_client(_raop_player))
         self._audio_source_task = asyncio.create_task(self._audio_streamer())
 
@@ -88,20 +88,24 @@ class RaopStreamSession:
             with suppress(asyncio.CancelledError):
                 await self._audio_source_task
         await asyncio.gather(
-            *[self.remove_client(x) for x in self._sync_clients],
+            *[self.remove_client(x) for x in self.sync_clients],
             return_exceptions=True,
         )
 
     async def remove_client(self, airplay_player: AirPlayPlayer) -> None:
         """Remove a sync client from the session."""
-        if airplay_player not in self._sync_clients:
+        if airplay_player not in self.sync_clients:
             return
         assert airplay_player.raop_stream
         assert airplay_player.raop_stream.session == self
         async with self._lock:
-            self._sync_clients.remove(airplay_player)
-        await airplay_player.cmd_stop()
+            self.sync_clients.remove(airplay_player)
+        await airplay_player.raop_stream.stop()
         airplay_player.raop_stream = None
+        # if this was the last client, stop the session
+        if not self.sync_clients:
+            await self.stop()
+            return
 
     async def add_client(self, airplay_player: AirPlayPlayer) -> None:
         """Add a sync client to the session."""
@@ -123,7 +127,7 @@ class RaopStreamSession:
         # this is the easiest way to ensure the new audio source is used
         # as quickly as possible, without waiting for the buffers to be drained
         # it also allows to change the player settings such as DSP on the fly
-        for sync_client in self._sync_clients:
+        for sync_client in self.sync_clients:
             if not sync_client.raop_stream:
                 continue  # guard
             sync_client.raop_stream.start_ffmpeg_stream()
@@ -135,7 +139,7 @@ class RaopStreamSession:
             async for chunk in self._audio_source:
                 async with self._lock:
                     sync_clients = [
-                        x for x in self._sync_clients if x.raop_stream and x.raop_stream.running
+                        x for x in self.sync_clients if x.raop_stream and x.raop_stream.running
                     ]
                     if not sync_clients:
                         return
@@ -149,7 +153,7 @@ class RaopStreamSession:
                 await asyncio.gather(
                     *[
                         x.raop_stream.write_eof()
-                        for x in self._sync_clients
+                        for x in self.sync_clients
                         if x.raop_stream and x.raop_stream.running
                     ],
                     return_exceptions=True,
@@ -311,6 +315,9 @@ class RaopStream:
             await self._cliraop_proc.close(True)
         if self._ffmpeg_proc and not self._ffmpeg_proc.closed:
             await self._ffmpeg_proc.close(True)
+        if mass_player := self.mass.players.get(self.airplay_player.player_id):
+            mass_player.state = PlayerState.IDLE
+            self.mass.players.update(mass_player.player_id)
 
     async def write_chunk(self, chunk: bytes) -> None:
         """Write a (pcm) audio chunk."""
@@ -457,10 +464,6 @@ class RaopStream:
                 break
             logger.log(VERBOSE_LOG_LEVEL, line)
 
-        # if we reach this point, the process exited
-        if airplay_player.raop_stream == self:
-            mass_player.state = PlayerState.IDLE
-            self.mass.players.update(airplay_player.player_id)
         # ensure we're cleaned up afterwards (this also logs the returncode)
         await self.stop()