From: Marcel van der Veldt Date: Mon, 7 Apr 2025 22:37:10 +0000 (+0200) Subject: Various small bugfixes and enhancements (#2116) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=6bceedc95412ceffea09bb79da1d7ec389cb5b08;p=music-assistant-server.git Various small bugfixes and enhancements (#2116) --- diff --git a/music_assistant/controllers/player_queues.py b/music_assistant/controllers/player_queues.py index c83b34a9..897812cb 100644 --- a/music_assistant/controllers/player_queues.py +++ b/music_assistant/controllers/player_queues.py @@ -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) diff --git a/music_assistant/providers/airplay/raop.py b/music_assistant/providers/airplay/raop.py index d2e2751d..a8395859 100644 --- a/music_assistant/providers/airplay/raop.py +++ b/music_assistant/providers/airplay/raop.py @@ -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).""" diff --git a/music_assistant/providers/sonos/player.py b/music_assistant/providers/sonos/player.py index e1c33dc5..214106ff 100644 --- a/music_assistant/providers/sonos/player.py +++ b/music_assistant/providers/sonos/player.py @@ -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 diff --git a/music_assistant/providers/sonos/provider.py b/music_assistant/providers/sonos/provider.py index 51234e97..e7cf3251 100644 --- a/music_assistant/providers/sonos/provider.py +++ b/music_assistant/providers/sonos/provider.py @@ -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: