Fix AirPlay playback cuts of last few seconds of playback (#2793)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 11 Dec 2025 23:52:40 +0000 (00:52 +0100)
committerGitHub <noreply@github.com>
Thu, 11 Dec 2025 23:52:40 +0000 (00:52 +0100)
music_assistant/helpers/named_pipe.py
music_assistant/models/player.py
music_assistant/providers/airplay/bin/cliraop-linux-aarch64
music_assistant/providers/airplay/bin/cliraop-linux-x86_64
music_assistant/providers/airplay/bin/cliraop-macos-arm64
music_assistant/providers/airplay/protocols/_protocol.py
music_assistant/providers/airplay/protocols/raop.py
music_assistant/providers/airplay/stream_session.py
music_assistant/providers/airplay_receiver/__init__.py

index f26d0b9fe979fa19622d362cd68569b4cbe575cf..7c9e1b30a0d226e730832261b9f4edf85e57e7fa 100644 (file)
@@ -4,26 +4,20 @@ from __future__ import annotations
 
 import asyncio
 import os
-import time
+import stat
 from contextlib import suppress
-from typing import TYPE_CHECKING
-
-if TYPE_CHECKING:
-    from logging import Logger
 
 
 class AsyncNamedPipeWriter:
     """Simple async writer for named pipes using thread pool for blocking I/O."""
 
-    def __init__(self, pipe_path: str, logger: Logger | None = None) -> None:
+    def __init__(self, pipe_path: str) -> None:
         """Initialize named pipe writer.
 
         Args:
             pipe_path: Path to the named pipe
-            logger: Optional logger for debug/error messages
         """
         self._pipe_path = pipe_path
-        self.logger = logger
 
     @property
     def path(self) -> str:
@@ -34,25 +28,20 @@ class AsyncNamedPipeWriter:
         """Create the named pipe (if it does not exist)."""
 
         def _create() -> None:
-            with suppress(FileExistsError):
+            try:
                 os.mkfifo(self._pipe_path)
-                # Should we handle the FileExistsError and check to make
-                # sure the file is indeed a named pipe using os.stat()
-                # and if it isn't then delete and re-create?
+            except FileExistsError:
+                # Check if existing file is actually a named pipe
+                file_stat = os.stat(self._pipe_path)
+                if not stat.S_ISFIFO(file_stat.st_mode):
+                    # Not a FIFO - remove and recreate
+                    os.remove(self._pipe_path)
+                    os.mkfifo(self._pipe_path)
 
         await asyncio.to_thread(_create)
 
-    async def write(self, data: bytes, log_slow_writes: bool = True) -> None:
-        """Write data to the named pipe (blocking operation runs in thread).
-
-        Args:
-            data: Data to write to the pipe
-            log_slow_writes: Whether to log slow writes (>5s)
-
-        Raises:
-            RuntimeError: If pipe is not open
-        """
-        start_time = time.time()
+    async def write(self, data: bytes) -> None:
+        """Write data to the named pipe (blocking operation runs in thread)."""
 
         def _write() -> None:
             with open(self._pipe_path, "wb") as pipe_file:
@@ -61,17 +50,6 @@ class AsyncNamedPipeWriter:
         # Run blocking write in thread pool
         await asyncio.to_thread(_write)
 
-        if log_slow_writes:
-            elapsed = time.time() - start_time
-            # Only log if it took more than 5 seconds (real stall)
-            if elapsed > 5.0 and self.logger:
-                self.logger.error(
-                    "!!! STALLED PIPE WRITE: Took %.3fs to write %d bytes to %s",
-                    elapsed,
-                    len(data),
-                    self._pipe_path,
-                )
-
     async def remove(self) -> None:
         """Remove the named pipe."""
 
index 480d647581d209a3c5b877d90c995b2bda302ca1..dcc0092363769c98191dae33e8d4b5c0a59971f2 100644 (file)
@@ -46,6 +46,7 @@ from music_assistant_models.unique_list import UniqueList
 from propcache import under_cached_property as cached_property
 
 from music_assistant.constants import (
+    ATTR_ANNOUNCEMENT_IN_PROGRESS,
     ATTR_FAKE_MUTE,
     ATTR_FAKE_POWER,
     ATTR_FAKE_VOLUME,
@@ -1300,6 +1301,13 @@ class Player(ABC):
 
     def __calculate_current_media(self) -> PlayerMedia | None:
         """Calculate the current media for the player."""
+        if self.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS):
+            # if an announcement is in progress, return announcement details
+            return PlayerMedia(
+                uri="announcement",
+                media_type=MediaType.ANNOUNCEMENT,
+                title="ANNOUNCEMENT",
+            )
         # if the player is grouped/synced, use the current_media of the group/parent player
         if parent_player_id := (self.active_group or self.synced_to):
             if parent_player := self.mass.players.get(parent_player_id):
index 3375e7d6c484a5241a149a6b44ebbc0defe5c6b8..f8367fe3d0724f0611900283dbe5d6f705ea5064 100755 (executable)
Binary files a/music_assistant/providers/airplay/bin/cliraop-linux-aarch64 and b/music_assistant/providers/airplay/bin/cliraop-linux-aarch64 differ
index 1fff3ba4c790f47848902788bc672e35b13c3260..29c4c1b08d2578c5cc661c6f5f2539ed2088f547 100755 (executable)
Binary files a/music_assistant/providers/airplay/bin/cliraop-linux-x86_64 and b/music_assistant/providers/airplay/bin/cliraop-linux-x86_64 differ
index c630463abe163fa00b84bd9df4b6160de2cb3378..4fb8f38731a89b0b9a539213aafe269e19e38922 100755 (executable)
Binary files a/music_assistant/providers/airplay/bin/cliraop-macos-arm64 and b/music_assistant/providers/airplay/bin/cliraop-macos-arm64 differ
index 85cd9aa5f109f567f36a5107b4852f2636f43f26..5f3afefddfcb6a9510afa9792684ddeb1ae96ff6 100644 (file)
@@ -55,11 +55,9 @@ class AirPlayProtocol(ABC):
         self._cli_proc: AsyncProcess | None = None
         self.audio_pipe = AsyncNamedPipeWriter(
             f"/tmp/{self.player.protocol.value}-{self.player.player_id}-{self.active_remote_id}-audio",  # noqa: S108
-            self.logger,
         )
         self.commands_pipe = AsyncNamedPipeWriter(
             f"/tmp/{self.player.protocol.value}-{self.player.player_id}-{self.active_remote_id}-cmd",  # noqa: S108
-            self.logger,
         )
         # State tracking
         self._stopped = False
index d3cd558132b2ee27a9c8be56ff699c4d633fd027..1396d38a51f08ae59c5122219f9081ef87972e62 100644 (file)
@@ -196,7 +196,6 @@ class RaopStream(AirPlayProtocol):
             logger.log(VERBOSE_LOG_LEVEL, line)
             await asyncio.sleep(0)  # Yield to event loop
 
-        # ensure we're cleaned up afterwards (this also logs the returncode)
         logger.debug("CLIRaop stderr reader ended")
         if not self._stopped:
-            await self.stop()
+            self.player.set_state_from_stream(state=PlaybackState.IDLE, elapsed_time=0)
index aa56f2ccfb9e5326dd9de561acee3209db94f812..856b243fbb4f6c1d596abca5cd01d54d76cb0d76 100644 (file)
@@ -362,9 +362,6 @@ class AirPlayStreamSession:
             await ffmpeg.write_eof()
             await ffmpeg.wait_with_timeout(30)
             del ffmpeg
-        assert airplay_player.stream  # for type checker
-        # then stop the player stream
-        await airplay_player.stream.stop()
 
     async def _send_metadata(self, progress: int | None, metadata: PlayerMedia | None) -> None:
         """Send metadata to all players."""
index 005dcb0baf6771905981e4ceb8f58a0f9ddeb387..9510b29bdc441c188950dc8cde4e768f8ca99cd2 100644 (file)
@@ -115,8 +115,8 @@ class AirPlayReceiverProvider(PluginProvider):
         # Initialize named pipe helpers
         audio_pipe_path = f"/tmp/ma_airplay_audio_{self.instance_id}"  # noqa: S108
         metadata_pipe_path = f"/tmp/ma_airplay_metadata_{self.instance_id}"  # noqa: S108
-        self.audio_pipe = AsyncNamedPipeWriter(audio_pipe_path, self.logger)
-        self.metadata_pipe = AsyncNamedPipeWriter(metadata_pipe_path, self.logger)
+        self.audio_pipe = AsyncNamedPipeWriter(audio_pipe_path)
+        self.metadata_pipe = AsyncNamedPipeWriter(metadata_pipe_path)
         self.config_file = f"/tmp/ma_shairport_sync_{self.instance_id}.conf"  # noqa: S108
         # Use port 7000+ for AirPlay 2 compatibility
         # Each instance gets a unique port: 7000, 7001, 7002, etc.
@@ -269,7 +269,7 @@ class AirPlayReceiverProvider(PluginProvider):
         """
         self.logger.debug("Writing silence to audio pipe to unblock stream")
         silence = b"\x00" * 176400  # 1 second of silence in PCM_S16LE stereo 44.1kHz
-        await self.audio_pipe.write(silence, log_slow_writes=False)
+        await self.audio_pipe.write(silence)
 
     def _process_shairport_log_line(self, line: str) -> None:
         """Process a log line from shairport-sync stderr.