Various small bugfixes and enhancements (#2116)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Mon, 7 Apr 2025 22:37:10 +0000 (00:37 +0200)
committerGitHub <noreply@github.com>
Mon, 7 Apr 2025 22:37:10 +0000 (00:37 +0200)
music_assistant/controllers/player_queues.py
music_assistant/providers/airplay/raop.py
music_assistant/providers/sonos/player.py
music_assistant/providers/sonos/provider.py

index c83b34a9e941a860e6a24fd3d1b6af1d7f7f71b5..897812cb29260600ce6106a904ffb1078adbbc46 100644 (file)
@@ -384,7 +384,8 @@ class PlayerQueuesController(CoreController):
         # this makes sure that playback has priority over other requests that may be
         # happening in the background
         BYPASS_THROTTLER.set(True)
-        queue = self._queues[queue_id]
+        if not (queue := self.get(queue_id)):
+            raise PlayerUnavailableError(f"Queue {queue_id} is not available")
         # always fetch the underlying player so we can raise early if its not available
         queue_player = self.mass.players.get(queue_id, True)
         if queue_player.announcement_in_progress:
@@ -601,7 +602,8 @@ class PlayerQueuesController(CoreController):
         - queue_id: queue_id of the playerqueue to handle the command.
         """
         if (queue := self.get(queue_id)) and queue.active:
-            queue.resume_pos = queue.corrected_elapsed_time
+            if queue.state == PlayerState.PLAYING:
+                queue.resume_pos = queue.corrected_elapsed_time
         # forward the actual command to the player provider
         if player_provider := self.mass.players.get_player_provider(queue.queue_id):
             await player_provider.cmd_stop(queue_id)
@@ -633,7 +635,8 @@ class PlayerQueuesController(CoreController):
         - queue_id: queue_id of the playerqueue to handle the command.
         """
         if queue := self._queues.get(queue_id):
-            queue.resume_pos = queue.corrected_elapsed_time
+            if queue.state == PlayerState.PLAYING:
+                queue.resume_pos = queue.corrected_elapsed_time
         # forward the actual command to the player controller
         await self.mass.players.cmd_pause(queue_id)
 
index d2e2751d1a6ee9b1eddbdefae7a64f6d0715c419..a83958594441d91a4b85d2e99f674052afaf9666 100644 (file)
@@ -111,13 +111,6 @@ class RaopStreamSession:
 
     async def replace_stream(self, audio_source: AsyncGenerator[bytes, None]) -> None:
         """Replace the audio source of the stream."""
-        # cancel the per-player ffmpeg reader tasks
-        for _raop_player in self._sync_clients:
-            assert _raop_player.raop_stream  # for type checker
-            assert _raop_player.raop_stream.ffmpeg_reader_task  # for type checker
-            _raop_player.raop_stream.ffmpeg_reader_task.cancel()
-            with suppress(asyncio.CancelledError):
-                await _raop_player.raop_stream.ffmpeg_reader_task
         # cancel the current audio source task
         assert self._audio_source_task  # for type checker
         self._audio_source_task.cancel()
@@ -126,11 +119,14 @@ class RaopStreamSession:
         # set new audio source and restart the stream
         self._audio_source = audio_source
         self._audio_source_task = asyncio.create_task(self._audio_streamer())
-        for _raop_player in self._sync_clients:
-            assert _raop_player.raop_stream  # for type checker
-            _raop_player.raop_stream.ffmpeg_reader_task = self.mass.create_task(
-                _raop_player.raop_stream.ffmpeg_reader()
-            )
+        # restart the (player-specific) ffmpeg stream for all players
+        # 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:
+            if not sync_client.raop_stream:
+                continue  # guard
+            sync_client.raop_stream.start_ffmpeg_stream()
 
     async def _audio_streamer(self) -> None:
         """Stream audio to all players."""
@@ -199,7 +195,7 @@ class RaopStream:
         self._stderr_reader_task: asyncio.Task[None] | None = None
         self._cliraop_proc: AsyncProcess | None = None
         self._ffmpeg_proc: AsyncProcess | None = None
-        self.ffmpeg_reader_task: asyncio.Task[None] | None = None
+        self._ffmpeg_reader_task: asyncio.Task[None] | None = None
         self._started = asyncio.Event()
         self._stopped = False
         self._total_bytes_sent = 0
@@ -251,10 +247,10 @@ class RaopStream:
         )
         # ffmpeg handles the player specific stream + filters and pipes
         # audio to the cliraop process
-        self.ffmpeg_reader_task = self.mass.create_task(self.ffmpeg_reader())
+        self.start_ffmpeg_stream()
 
         # cliraop is the binary that handles the actual raop streaming to the player
-        # this is a slightly modified bversion of philippe44's libraop
+        # this is a slightly modified version of philippe44's libraop
         # https://github.com/music-assistant/libraop
         # we use this intermediate binary to do the actual streaming because attempts to do
         # so using pure python (e.g. pyatv) were not successful due to the realtime nature
@@ -285,15 +281,16 @@ class RaopStream:
         if platform.system() == "Darwin":
             os.environ["DYLD_LIBRARY_PATH"] = "/usr/local/lib"
         await self._cliraop_proc.start()
-        # read first 10 lines of stderr to get the initial status
-        for _ in range(10):
+        # read first 20 lines of stderr to get the initial status
+        for _ in range(20):
             line = (await self._cliraop_proc.read_stderr()).decode("utf-8", errors="ignore")
             self.airplay_player.logger.debug(line)
             if "connected to " in line:
                 self._started.set()
                 break
             if "Cannot connect to AirPlay device" in line:
-                self.ffmpeg_reader_task.cancel()
+                if self._ffmpeg_reader_task:
+                    self._ffmpeg_reader_task.cancel()
                 raise PlayerCommandFailed("Cannot connect to AirPlay device")
         # repeat sending the volume level to the player because some players seem
         # to ignore it the first time
@@ -304,19 +301,16 @@ class RaopStream:
 
     async def stop(self) -> None:
         """Stop playback and cleanup."""
-        if self._stopped or not self._cliraop_proc:
-            return
-        if self._cliraop_proc.proc and not self._cliraop_proc.closed:
-            await self.send_cli_command("ACTION=STOP")
+        await self.send_cli_command("ACTION=STOP")
         self._stopped = True
-        with suppress(asyncio.TimeoutError):
-            await self._cliraop_proc.wait_with_timeout(2)
         if self._stderr_reader_task and not self._stderr_reader_task.done():
             self._stderr_reader_task.cancel()
-        if self.ffmpeg_reader_task and not self.ffmpeg_reader_task.done():
-            self.ffmpeg_reader_task.cancel()
-        if self._cliraop_proc.proc and not self._cliraop_proc.closed:
+        if self._ffmpeg_reader_task and not self._ffmpeg_reader_task.done():
+            self._ffmpeg_reader_task.cancel()
+        if self._cliraop_proc and not self._cliraop_proc.closed:
             await self._cliraop_proc.close(True)
+        if self._ffmpeg_proc and not self._ffmpeg_proc.closed:
+            await self._ffmpeg_proc.close(True)
 
     async def write_chunk(self, chunk: bytes) -> None:
         """Write a (pcm) audio chunk."""
@@ -336,8 +330,8 @@ class RaopStream:
 
     async def send_cli_command(self, command: str) -> None:
         """Send an interactive command to the running CLIRaop binary."""
-        if self._stopped:
-            raise RuntimeError("Stream is already stopped")
+        if self._stopped or not self._cliraop_proc or self._cliraop_proc.closed:
+            return
         await self._started.wait()
 
         if not command.endswith("\n"):
@@ -352,7 +346,17 @@ class RaopStream:
         self.airplay_player.last_command_sent = time.time()
         await asyncio.to_thread(send_data)
 
-    async def ffmpeg_reader(self) -> None:
+    def start_ffmpeg_stream(self) -> None:
+        """Start (or replace) the player-specific ffmpeg stream to feed cliraop."""
+        # cancel existing ffmpeg reader task
+        if self._ffmpeg_reader_task and not self._ffmpeg_reader_task.done():
+            self._ffmpeg_reader_task.cancel()
+        if self._ffmpeg_proc and not self._ffmpeg_proc.closed:
+            self.mass.create_task(self._ffmpeg_proc.close(True))
+        # start new ffmpeg reader task
+        self._ffmpeg_reader_task = self.mass.create_task(self._ffmpeg_reader())
+
+    async def _ffmpeg_reader(self) -> None:
         """Read audio from the audio source and pipe it to the CLIRaop process."""
         self._ffmpeg_proc = FFMpeg(
             audio_input="-",
@@ -368,28 +372,26 @@ class RaopStream:
         self._stream_bytes_sent = 0
         mass_player = self.mass.players.get(self.airplay_player.player_id)
         assert mass_player  # for type checker
-        try:
-            await self._ffmpeg_proc.start()
-            chunksize = get_chunksize(AIRPLAY_PCM_FORMAT)
-            # wait for cliraop to be ready
-            await asyncio.wait_for(self._started.wait(), 20)
-            async for chunk in self._ffmpeg_proc.iter_chunked(chunksize):
-                if self._stopped:
-                    break
-                if not self._cliraop_proc or self._cliraop_proc.closed:
-                    break
-                await self._cliraop_proc.write(chunk)
-                self._stream_bytes_sent += len(chunk)
-                self._total_bytes_sent += len(chunk)
-                del chunk
-                # we base elapsed time on the amount of bytes sent
-                # so we can account for reusing the same session for multiple streams
-                mass_player.elapsed_time = self._stream_bytes_sent / chunksize
-                mass_player.elapsed_time_last_updated = time.time()
-            if self._cliraop_proc and not self._cliraop_proc.closed:
-                await self._cliraop_proc.write_eof()
-        finally:
-            await self._ffmpeg_proc.close()
+        await self._ffmpeg_proc.start()
+        chunksize = get_chunksize(AIRPLAY_PCM_FORMAT)
+        # wait for cliraop to be ready
+        await asyncio.wait_for(self._started.wait(), 20)
+        async for chunk in self._ffmpeg_proc.iter_chunked(chunksize):
+            if self._stopped:
+                break
+            if not self._cliraop_proc or self._cliraop_proc.closed:
+                break
+            await self._cliraop_proc.write(chunk)
+            self._stream_bytes_sent += len(chunk)
+            self._total_bytes_sent += len(chunk)
+            del chunk
+            # we base elapsed time on the amount of bytes sent
+            # so we can account for reusing the same session for multiple streams
+            mass_player.elapsed_time = self._stream_bytes_sent / chunksize
+            mass_player.elapsed_time_last_updated = time.time()
+        # if we reach this point, the process exited, most likely because the stream ended
+        if self._cliraop_proc and not self._cliraop_proc.closed:
+            await self._cliraop_proc.write_eof()
 
     async def _stderr_reader(self) -> None:
         """Monitor stderr for the running CLIRaop process."""
@@ -453,7 +455,6 @@ class RaopStream:
             if "end of stream reached" in line:
                 logger.debug("End of stream reached")
                 break
-
             logger.log(VERBOSE_LOG_LEVEL, line)
 
         # if we reach this point, the process exited
@@ -461,8 +462,7 @@ class RaopStream:
             mass_player.state = PlayerState.IDLE
             self.mass.players.update(airplay_player.player_id)
         # ensure we're cleaned up afterwards (this also logs the returncode)
-        if not self._stopped:
-            await self.stop()
+        await self.stop()
 
     async def _send_metadata(self, queue: PlayerQueue) -> None:
         """Send metadata to player (and connected sync childs)."""
index e1c33dc5fb293514d931a6c696efbfd6d7ef935a..214106fff4d1b7aed36f34454a5d3768bdaa6481 100644 (file)
@@ -232,6 +232,16 @@ class SonosPlayer:
             if player_provider := self.mass.get_provider(airplay.provider):
                 await player_provider.cmd_pause(airplay.player_id)
             return
+        active_source = self.mass_player.active_source
+        if self.mass.player_queues.get(active_source):
+            # Sonos seems to be bugged when playing our queue tracks and we send pause,
+            # it can't resume the current track and simply aborts/skips it
+            # so we stop the player instead.
+            # https://github.com/music-assistant/support/issues/3758
+            # TODO: revisit this later once we implemented support for range requests
+            # as I have the feeling the pause issue is related to seek support (=range requests)
+            await self.cmd_stop()
+            return
         if not self.client.player.group.playback_actions.can_pause:
             await self.cmd_stop()
             return
index 51234e974ea82078c27165a58e6c1f629e876233..e7cf32511986af2a74be0571c8d162b14a875a3c 100644 (file)
@@ -149,7 +149,12 @@ class SonosPlayerProvider(PlayerProvider):
             CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED,
             CONF_ENTRY_HTTP_PROFILE_DEFAULT_2,
             create_sample_rates_config_entry(
-                max_sample_rate=48000, max_bit_depth=24, safe_max_bit_depth=24, hidden=True
+                # set safe max bit depth to 16 bits because the older Sonos players
+                # do not support 24 bit playback (e.g. Play:1)
+                max_sample_rate=48000,
+                max_bit_depth=24,
+                safe_max_bit_depth=16,
+                hidden=False,
             ),
         )
         if not (sonos_player := self.sonos_players.get(player_id)):
@@ -215,17 +220,6 @@ class SonosPlayerProvider(PlayerProvider):
     async def cmd_pause(self, player_id: str) -> None:
         """Send PAUSE command to given player."""
         if sonos_player := self.sonos_players[player_id]:
-            active_source = sonos_player.mass_player.active_source
-            if self.mass.player_queues.get(active_source):
-                # Sonos seems to be bugged when playing our queue tracks and we send pause,
-                # it can't resume the current track and simply aborts/skips it
-                # so we stop the player instead.
-                # https://github.com/music-assistant/support/issues/3758
-                # TODO: revisit this later and find out how this can be so bugged
-                # probably some strange DLNA flag or whatever needs to be set.
-                await self.cmd_stop(player_id)
-                return
-
             await sonos_player.cmd_pause()
 
     async def cmd_seek(self, player_id: str, position: int) -> None: