Add (initial) support for AirPlay 2 (#2571)
authorBrad Keifer <15224368+bradkeifer@users.noreply.github.com>
Sun, 16 Nov 2025 19:26:09 +0000 (06:26 +1100)
committerGitHub <noreply@github.com>
Sun, 16 Nov 2025 19:26:09 +0000 (20:26 +0100)
music_assistant/helpers/named_pipe.py
music_assistant/providers/airplay/bin/cliap2-linux-aarch64 [new file with mode: 0755]
music_assistant/providers/airplay/bin/cliap2-linux-x86_64 [new file with mode: 0755]
music_assistant/providers/airplay/bin/cliap2-macos-arm64 [new file with mode: 0755]
music_assistant/providers/airplay/bin/cliap2-macos-x86_64 [new file with mode: 0755]
music_assistant/providers/airplay/constants.py
music_assistant/providers/airplay/helpers.py
music_assistant/providers/airplay/player.py
music_assistant/providers/airplay/protocols/airplay2.py

index 3e315e6de8fff95ffcd4bc80f8dbb9267b932cfd..f26d0b9fe979fa19622d362cd68569b4cbe575cf 100644 (file)
@@ -36,6 +36,9 @@ class AsyncNamedPipeWriter:
         def _create() -> None:
             with suppress(FileExistsError):
                 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?
 
         await asyncio.to_thread(_create)
 
diff --git a/music_assistant/providers/airplay/bin/cliap2-linux-aarch64 b/music_assistant/providers/airplay/bin/cliap2-linux-aarch64
new file mode 100755 (executable)
index 0000000..5ae7349
Binary files /dev/null and b/music_assistant/providers/airplay/bin/cliap2-linux-aarch64 differ
diff --git a/music_assistant/providers/airplay/bin/cliap2-linux-x86_64 b/music_assistant/providers/airplay/bin/cliap2-linux-x86_64
new file mode 100755 (executable)
index 0000000..fc161ec
Binary files /dev/null and b/music_assistant/providers/airplay/bin/cliap2-linux-x86_64 differ
diff --git a/music_assistant/providers/airplay/bin/cliap2-macos-arm64 b/music_assistant/providers/airplay/bin/cliap2-macos-arm64
new file mode 100755 (executable)
index 0000000..c1f8e4d
Binary files /dev/null and b/music_assistant/providers/airplay/bin/cliap2-macos-arm64 differ
diff --git a/music_assistant/providers/airplay/bin/cliap2-macos-x86_64 b/music_assistant/providers/airplay/bin/cliap2-macos-x86_64
new file mode 100755 (executable)
index 0000000..ad27e05
Binary files /dev/null and b/music_assistant/providers/airplay/bin/cliap2-macos-x86_64 differ
index b03dcf0613e4458610c6570a5a5100724e981214..1487e221f51d5cd66dcf14aa3ecf2074fee2f5f5 100644 (file)
@@ -61,9 +61,9 @@ AIRPLAY_PCM_FORMAT = AudioFormat(
     content_type=ContentType.from_bit_depth(16), sample_rate=44100, bit_depth=16
 )
 
-BROKEN_RAOP_MODELS = (
-    # A recent fw update of newer gen Sonos speakers block RAOP (airplay 1) support,
-    # basically rendering our airplay implementation useless on these devices.
+BROKEN_AIRPLAY_MODELS = (
+    # A recent fw update of newer gen Sonos speakers have AirPlay issues,
+    # basically rendering our (both AP2 and RAOP) implementation useless on these devices.
     # This list contains the models that are known to have this issue.
     # Hopefully the issue won't spread to other models.
     ("Sonos", "Era 100"),
@@ -73,6 +73,10 @@ BROKEN_RAOP_MODELS = (
     ("Sonos", "Arc Ultra"),
     # Samsung has been repeatedly being reported as having issues with AirPlay 1/raop
     ("Samsung", "*"),
+)
+
+AIRPLAY_2_DEFAULT_MODELS = (
+    # Models that are known to work better with AirPlay 2 protocol instead of RAOP
     ("Ubiquiti Inc.", "*"),
     ("Juke Audio", "*"),
 )
index 74140b4063c22da3f84a442daabfeaa10a98ec94..2ae3d576b58b4bd9eb8e0d0ee0a43f4db4341562 100644 (file)
@@ -11,7 +11,11 @@ from typing import TYPE_CHECKING
 from zeroconf import IPVersion
 
 from music_assistant.helpers.process import check_output
-from music_assistant.providers.airplay.constants import BROKEN_RAOP_MODELS, StreamingProtocol
+from music_assistant.providers.airplay.constants import (
+    AIRPLAY_2_DEFAULT_MODELS,
+    BROKEN_AIRPLAY_MODELS,
+    StreamingProtocol,
+)
 
 if TYPE_CHECKING:
     from zeroconf.asyncio import AsyncServiceInfo
@@ -110,14 +114,22 @@ def get_primary_ip_address_from_zeroconf(discovery_info: AsyncServiceInfo) -> st
     return None
 
 
-def is_broken_raop_model(manufacturer: str, model: str) -> bool:
+def is_broken_airplay_model(manufacturer: str, model: str) -> bool:
     """Check if a model is known to have broken RAOP support."""
-    for broken_manufacturer, broken_model in BROKEN_RAOP_MODELS:
+    for broken_manufacturer, broken_model in BROKEN_AIRPLAY_MODELS:
         if broken_manufacturer in (manufacturer, "*") and broken_model in (model, "*"):
             return True
     return False
 
 
+def is_airplay2_preferred_model(manufacturer: str, model: str) -> bool:
+    """Check if a model is known to work better with AirPlay 2 protocol."""
+    for ap2_manufacturer, ap2_model in AIRPLAY_2_DEFAULT_MODELS:
+        if ap2_manufacturer in (manufacturer, "*") and ap2_model in (model, "*"):
+            return True
+    return False
+
+
 async def get_cli_binary(protocol: StreamingProtocol) -> str:
     """Find the correct raop/airplay binary belonging to the platform.
 
@@ -140,12 +152,9 @@ async def get_cli_binary(protocol: StreamingProtocol) -> str:
                 ]
                 passing_output = "cliraop check"
             else:
-                config_file = os.path.join(os.path.dirname(__file__), "bin", "cliap2.conf")
                 args = [
                     cli_path,
                     "--testrun",
-                    "--config",
-                    config_file,
                 ]
 
             returncode, output = await check_output(*args)
index d32080c4cef356cde464af8ec9b62d45683df92c..5d59e75e1ce4027031cbb5aaef550ab4df589eb4 100644 (file)
@@ -39,7 +39,11 @@ from .constants import (
     RAOP_DISCOVERY_TYPE,
     StreamingProtocol,
 )
-from .helpers import get_primary_ip_address_from_zeroconf, is_broken_raop_model
+from .helpers import (
+    get_primary_ip_address_from_zeroconf,
+    is_airplay2_preferred_model,
+    is_broken_airplay_model,
+)
 from .stream_session import AirPlayStreamSession
 
 if TYPE_CHECKING:
@@ -50,13 +54,16 @@ if TYPE_CHECKING:
     from .provider import AirPlayProvider
 
 
-BROKEN_RAOP_WARN = ConfigEntry(
-    key="broken_raop",
+BROKEN_AIRPLAY_WARN = ConfigEntry(
+    key="BROKEN_AIRPLAY",
     type=ConfigEntryType.ALERT,
     default_value=None,
     required=False,
-    label="This player is known to have broken AirPlay 1 (RAOP) support. "
-    "Playback may fail or simply be silent. There is no workaround for this issue at the moment.",
+    label="This player is known to have broken AirPlay support. "
+    "Playback may fail or simply be silent. "
+    "There is no workaround for this issue at the moment. \n"
+    "If you already enforced AirPlay 2 on the player and it remains silent, "
+    "this is one of the known broken models. Only remedy is to nag the manufacturer for a fix.",
 )
 
 
@@ -100,7 +107,7 @@ class AirPlayPlayer(Player):
         }
         self._attr_volume_level = initial_volume
         self._attr_can_group_with = {provider.lookup_key}
-        self._attr_enabled_by_default = not is_broken_raop_model(manufacturer, model)
+        self._attr_enabled_by_default = not is_broken_airplay_model(manufacturer, model)
 
     @cached_property
     def protocol(self) -> StreamingProtocol:
@@ -156,7 +163,6 @@ class AirPlayPlayer(Player):
             ConfigEntry(
                 key=CONF_AIRPLAY_PROTOCOL,
                 type=ConfigEntryType.INTEGER,
-                default_value=StreamingProtocol.RAOP.value,
                 required=False,
                 label="AirPlay version to use for streaming",
                 description="AirPlay version 1 protocol uses RAOP.\n"
@@ -168,16 +174,22 @@ class AirPlayPlayer(Player):
                     ConfigValueOption("AirPlay 1 (RAOP)", StreamingProtocol.RAOP.value),
                     ConfigValueOption("AirPlay 2", StreamingProtocol.AIRPLAY2.value),
                 ],
-                hidden=True,
+                default_value=StreamingProtocol.AIRPLAY2.value
+                if is_airplay2_preferred_model(
+                    self.device_info.manufacturer, self.device_info.model
+                )
+                else StreamingProtocol.RAOP.value,
             ),
             ConfigEntry(
                 key=CONF_ENCRYPTION,
                 type=ConfigEntryType.BOOLEAN,
-                default_value=False,
+                default_value=True,
                 label="Enable encryption",
                 description="Enable encrypted communication with the player, "
-                "some (3rd party) players require this.",
+                "some (3rd party) players require to disable this.",
                 category="airplay",
+                depends_on=CONF_AIRPLAY_PROTOCOL,
+                depends_on_value=StreamingProtocol.RAOP.value,
             ),
             ConfigEntry(
                 key=CONF_ALAC_ENCODE,
@@ -187,6 +199,8 @@ class AirPlayPlayer(Player):
                 description="Save some network bandwidth by sending the audio as "
                 "(lossless) ALAC at the cost of a bit CPU.",
                 category="airplay",
+                depends_on=CONF_AIRPLAY_PROTOCOL,
+                depends_on_value=StreamingProtocol.RAOP.value,
             ),
             CONF_ENTRY_SYNC_ADJUST,
             ConfigEntry(
@@ -210,6 +224,8 @@ class AirPlayPlayer(Player):
                 "Recommended: 1000ms for stable playback.",
                 category="airplay",
                 range=(500, 2000),
+                depends_on=CONF_AIRPLAY_PROTOCOL,
+                depends_on_value=StreamingProtocol.RAOP.value,
             ),
             # airplay has fixed sample rate/bit depth so make this config entry static and hidden
             create_sample_rates_config_entry(
@@ -228,11 +244,13 @@ class AirPlayPlayer(Player):
                     "Enable this option to ignore these reports."
                 ),
                 category="airplay",
+                depends_on=CONF_AIRPLAY_PROTOCOL,
+                depends_on_value=StreamingProtocol.RAOP.value,
             ),
         ]
 
-        if is_broken_raop_model(self.device_info.manufacturer, self.device_info.model):
-            base_entries.insert(-1, BROKEN_RAOP_WARN)
+        if is_broken_airplay_model(self.device_info.manufacturer, self.device_info.model):
+            base_entries.insert(-1, BROKEN_AIRPLAY_WARN)
 
         return base_entries
 
@@ -407,7 +425,7 @@ class AirPlayPlayer(Player):
         """Send PLAY (unpause) command to player."""
         async with self._lock:
             if self.stream and self.stream.running:
-                await self.stream.send_cli_command("ACTION=PLAY")
+                await self.stream.send_cli_command("ACTION=PLAY\n")
 
     async def pause(self) -> None:
         """Send PAUSE command to player."""
@@ -420,7 +438,7 @@ class AirPlayPlayer(Player):
         async with self._lock:
             if not self.stream or not self.stream.running:
                 return
-            await self.stream.send_cli_command("ACTION=PAUSE")
+            await self.stream.send_cli_command("ACTION=PAUSE\n")
 
     async def play_media(self, media: PlayerMedia) -> None:
         """Handle PLAY MEDIA on given player."""
index d38df424f683059f49387c3a45fc15bcb62f7590..a55a9e4924c69f42ef4efc9bc49f5500f3a91383 100644 (file)
@@ -2,10 +2,7 @@
 
 from __future__ import annotations
 
-import asyncio
 import logging
-import os
-import platform
 
 from music_assistant_models.enums import PlaybackState
 from music_assistant_models.errors import PlayerCommandFailed
@@ -16,7 +13,7 @@ from music_assistant.providers.airplay.constants import (
     AIRPLAY2_MIN_LOG_LEVEL,
     CONF_READ_AHEAD_BUFFER,
 )
-from music_assistant.providers.airplay.helpers import get_cli_binary, get_ntp_timestamp
+from music_assistant.providers.airplay.helpers import get_cli_binary
 
 from ._protocol import AirPlayProtocol
 
@@ -31,14 +28,6 @@ class AirPlay2Stream(AirPlayProtocol):
     and we can send some interactive commands using another named pipe.
     """
 
-    _stderr_reader_task: asyncio.Task[None] | None = None
-
-    async def get_ntp(self) -> int:
-        """Get current NTP timestamp."""
-        # this can probably be removed now that we already get the ntp
-        # in python (within the stream session start)
-        return get_ntp_timestamp()
-
     @property
     def _cli_loglevel(self) -> int:
         """
@@ -46,7 +35,6 @@ class AirPlay2Stream(AirPlayProtocol):
 
         Ensures that minimum level required for required cliap2 stderr output is respected.
         """
-        force_verbose: bool = False  # just for now
         mass_level: int = 0
         match self.prov.logger.level:
             case logging.CRITICAL:
@@ -61,8 +49,6 @@ class AirPlay2Stream(AirPlayProtocol):
                 mass_level = 4
         if self.prov.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
             mass_level = 5
-        if force_verbose:
-            mass_level = 5  # always use max log level for now to capture all stderr output
         return max(mass_level, AIRPLAY2_MIN_LOG_LEVEL)
 
     async def start(self, start_ntp: int, skip: int = 0) -> None:
@@ -87,10 +73,9 @@ class AirPlay2Stream(AirPlayProtocol):
         # cliap2 is the binary that handles the actual streaming to the player
         # this binary leverages from the AirPlay2 support in owntones
         # https://github.com/music-assistant/cliairplay
+
         cli_args = [
             cli_binary,
-            "--config",
-            os.path.join(os.path.dirname(__file__), "bin", "cliap2.conf"),
             "--name",
             self.player.display_name,
             "--hostname",
@@ -110,16 +95,17 @@ class AirPlay2Stream(AirPlayProtocol):
             "--loglevel",
             str(self._cli_loglevel),
             "--pipe",
-            self.audio_named_pipe,
+            self.audio_pipe.path,
+            "--command_pipe",
+            self.commands_pipe.path,
         ]
+
         self.player.logger.debug(
             "Starting cliap2 process for player %s with args: %s",
             player_id,
             cli_args,
         )
-        self._cli_proc = AsyncProcess(cli_args, stdin=True, stderr=True, name="cliap2")
-        if platform.system() == "Darwin":
-            os.environ["DYLD_LIBRARY_PATH"] = "/usr/local/lib"
+        self._cli_proc = AsyncProcess(cli_args, stdin=False, stderr=True, name="cliap2")
         await self._cli_proc.start()
         # read up to first num_lines lines of stderr to get the initial status
         num_lines: int = 50
@@ -130,44 +116,30 @@ class AirPlay2Stream(AirPlayProtocol):
             self.player.logger.debug(line)
             if f"airplay: Adding AirPlay device '{self.player.display_name}'" in line:
                 self.player.logger.info("AirPlay device connected. Starting playback.")
-                self._started.set()
-                # Open pipes now that cliraop is ready
-                await self._open_pipes()
                 break
             if f"The AirPlay 2 device '{self.player.display_name}' failed" in line:
                 raise PlayerCommandFailed("Cannot connect to AirPlay device")
         # start reading the stderr of the cliap2 process from another task
-        self._stderr_reader_task = self.mass.create_task(self._stderr_reader())
+        self._cli_proc.attach_stderr_reader(self.mass.create_task(self._stderr_reader()))
 
     async def _stderr_reader(self) -> None:
         """Monitor stderr for the running CLIap2 process."""
         player = self.player
-        queue = self.mass.players.get_active_queue(player)
         logger = player.logger
-        lost_packets = 0
         if not self._cli_proc:
             return
         async for line in self._cli_proc.iter_stderr():
-            # TODO @bradkeifer make cliap2 work this way
-            if "elapsed milliseconds:" in line:
-                # this is received more or less every second while playing
-                # millis = int(line.split("elapsed milliseconds: ")[1])
-                # self.player.elapsed_time = (millis / 1000) - self.elapsed_time_correction
-                # self.player.elapsed_time_last_updated = time.time()
-                # NOTE: Metadata is now handled at the session level
-                pass
-            if "set pause" in line or "Pause at" in line:
+            if "Pause at" in line:
                 player.set_state_from_stream(state=PlaybackState.PAUSED)
-            if "Restarted at" in line or "restarting w/ pause" in line:
+            if "Restarted at" in line:
                 player.set_state_from_stream(state=PlaybackState.PLAYING)
-            if "restarting w/o pause" in line:
+            if "Starting at" in line:
                 # streaming has started
                 player.set_state_from_stream(state=PlaybackState.PLAYING, elapsed_time=0)
-            if "lost packet out of backlog" in line:
-                lost_packets += 1
-                if lost_packets == 100 and queue:
+            if "put delay detected" in line:
+                if "resetting all outputs" in line:
                     logger.error("High packet loss detected, restarting playback...")
-                    self.mass.create_task(self.mass.player_queues.resume(queue.queue_id, False))
+                    self.mass.create_task(self.mass.players.cmd_resume(self.player.player_id))
                 else:
                     logger.warning("Packet loss detected!")
             if "end of stream reached" in line: