From: Brad Keifer <15224368+bradkeifer@users.noreply.github.com> Date: Sun, 16 Nov 2025 19:26:09 +0000 (+1100) Subject: Add (initial) support for AirPlay 2 (#2571) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=e052f65c30921ee93b57cc3cbb5c43d12801d5f1;p=music-assistant-server.git Add (initial) support for AirPlay 2 (#2571) --- diff --git a/music_assistant/helpers/named_pipe.py b/music_assistant/helpers/named_pipe.py index 3e315e6d..f26d0b9f 100644 --- a/music_assistant/helpers/named_pipe.py +++ b/music_assistant/helpers/named_pipe.py @@ -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 index 00000000..5ae73492 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 index 00000000..fc161ec1 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 index 00000000..c1f8e4de 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 index 00000000..ad27e054 Binary files /dev/null and b/music_assistant/providers/airplay/bin/cliap2-macos-x86_64 differ diff --git a/music_assistant/providers/airplay/constants.py b/music_assistant/providers/airplay/constants.py index b03dcf06..1487e221 100644 --- a/music_assistant/providers/airplay/constants.py +++ b/music_assistant/providers/airplay/constants.py @@ -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", "*"), ) diff --git a/music_assistant/providers/airplay/helpers.py b/music_assistant/providers/airplay/helpers.py index 74140b40..2ae3d576 100644 --- a/music_assistant/providers/airplay/helpers.py +++ b/music_assistant/providers/airplay/helpers.py @@ -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) diff --git a/music_assistant/providers/airplay/player.py b/music_assistant/providers/airplay/player.py index d32080c4..5d59e75e 100644 --- a/music_assistant/providers/airplay/player.py +++ b/music_assistant/providers/airplay/player.py @@ -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.""" diff --git a/music_assistant/providers/airplay/protocols/airplay2.py b/music_assistant/providers/airplay/protocols/airplay2.py index d38df424..a55a9e49 100644 --- a/music_assistant/providers/airplay/protocols/airplay2.py +++ b/music_assistant/providers/airplay/protocols/airplay2.py @@ -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: