Fix pairing support for AirPlay
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 31 Oct 2025 13:42:56 +0000 (14:42 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 31 Oct 2025 13:45:51 +0000 (14:45 +0100)
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/player.py
music_assistant/providers/airplay/protocols/_protocol.py
music_assistant/providers/airplay/protocols/raop.py
music_assistant/providers/airplay/provider.py
music_assistant/providers/airplay/stream_session.py

index 4eae6a1346ac20a45800bd097caddca534966853..733bcc4b4b346746deea2ba7bb2e2552541c620c 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 dc6cedb9b54b222905b138258ad0c02c50a4e342..ae66ff26ff5a83a685f23b859c20fe9dde9d5f93 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 8de2c4cc12df0c48c769bf415064aa78ad2f4fb0..62a840dbea0f19056a463258edebeb70a81cd16b 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 40aa721fbd475e3f1b1a14b7fdd5ac3f960060ea..60882ce2c400cfd9e1c47b0ce6568abd5757fe4f 100644 (file)
@@ -122,6 +122,16 @@ class AirPlayPlayer(Player):
             return StreamingProtocol.AIRPLAY2
         return StreamingProtocol.RAOP
 
+    @property
+    def available(self) -> bool:
+        """Return if the player is currently available."""
+        if self._requires_pairing():
+            # check if we have credentials stored
+            credentials = self.config.get_value(CONF_AP_CREDENTIALS)
+            if not credentials:
+                return False
+        return super().available
+
     async def get_config_entries(
         self,
         action: str | None = None,
@@ -130,15 +140,17 @@ class AirPlayPlayer(Player):
         """Return all (provider/player specific) Config Entries for the given player (if any)."""
         base_entries = await super().get_config_entries()
 
+        require_pairing = self._requires_pairing()
+
         # Handle pairing actions
-        if action and self._requires_pairing():
+        if action and require_pairing:
             await self._handle_pairing_action(action=action, values=values)
 
         # Add pairing config entries for Apple TV and macOS devices
-        if self._requires_pairing():
-            base_entries = [*self._get_pairing_config_entries(), *base_entries]
+        if require_pairing:
+            base_entries = [*self._get_pairing_config_entries(values), *base_entries]
 
-        base_entries = await super().get_config_entries(action=action, values=values)
+        # Regular AirPlay config entries
         base_entries += [
             CONF_ENTRY_FLOW_MODE_ENFORCED,
             CONF_ENTRY_DEPRECATED_EQ_BASS,
@@ -223,71 +235,6 @@ class AirPlayPlayer(Player):
             ),
         ]
 
-        # Regular AirPlay config entries
-        base_entries.extend(
-            [
-                ConfigEntry(
-                    key=CONF_ENCRYPTION,
-                    type=ConfigEntryType.BOOLEAN,
-                    default_value=True,
-                    label="Enable encryption",
-                    description="Enable encrypted communication with the player, "
-                    "should by default be enabled for most devices.",
-                    category="airplay",
-                ),
-                ConfigEntry(
-                    key=CONF_ALAC_ENCODE,
-                    type=ConfigEntryType.BOOLEAN,
-                    default_value=True,
-                    label="Enable compression",
-                    description="Save some network bandwidth by sending the audio as "
-                    "(lossless) ALAC at the cost of a bit CPU.",
-                    category="airplay",
-                ),
-                CONF_ENTRY_SYNC_ADJUST,
-                ConfigEntry(
-                    key=CONF_PASSWORD,
-                    type=ConfigEntryType.SECURE_STRING,
-                    default_value=None,
-                    required=False,
-                    label="Device password",
-                    description="Some devices require a password to connect/play.",
-                    category="airplay",
-                ),
-                ConfigEntry(
-                    key=CONF_READ_AHEAD_BUFFER,
-                    type=ConfigEntryType.INTEGER,
-                    default_value=1000,
-                    required=False,
-                    label="Audio buffer (ms)",
-                    description="Amount of buffer (in milliseconds), "
-                    "the player should keep to absorb network throughput jitter. "
-                    "If you experience audio dropouts, try increasing this value.",
-                    category="airplay",
-                    range=(500, 3000),
-                ),
-                # airplay has fixed sample rate/bit depth so make this config entry
-                # static and hidden
-                create_sample_rates_config_entry(
-                    supported_sample_rates=[44100], supported_bit_depths=[16], hidden=True
-                ),
-                ConfigEntry(
-                    key=CONF_IGNORE_VOLUME,
-                    type=ConfigEntryType.BOOLEAN,
-                    default_value=False,
-                    label="Ignore volume reports sent by the device itself",
-                    description=(
-                        "The AirPlay protocol allows devices to report their own volume "
-                        "level. \n"
-                        "For some devices this is not reliable and can cause unexpected "
-                        "volume changes. \n"
-                        "Enable this option to ignore these reports."
-                    ),
-                    category="airplay",
-                ),
-            ]
-        )
-
         if is_broken_raop_model(self.device_info.manufacturer, self.device_info.model):
             base_entries.insert(-1, BROKEN_RAOP_WARN)
 
@@ -305,7 +252,9 @@ class AirPlayPlayer(Player):
         # Mac devices (including iMac, MacBook, Mac mini, Mac Pro, Mac Studio)
         return model.startswith(("Mac", "iMac"))
 
-    def _get_pairing_config_entries(self) -> list[ConfigEntry]:
+    def _get_pairing_config_entries(
+        self, values: dict[str, ConfigValueType] | None
+    ) -> list[ConfigEntry]:
         """Return pairing config entries for Apple TV and macOS devices.
 
         Uses cliraop for AirPlay/RAOP pairing.
@@ -313,59 +262,81 @@ class AirPlayPlayer(Player):
         entries: list[ConfigEntry] = []
 
         # Check if we have credentials stored
-        has_credentials = bool(self.config.get_value(CONF_AP_CREDENTIALS))
+        if values and (creds := values.get(CONF_AP_CREDENTIALS)):
+            credentials = str(creds)
+        else:
+            credentials = str(self.config.get_value(CONF_AP_CREDENTIALS) or "")
+        has_credentials = bool(credentials)
 
         if not has_credentials:
             # Show pairing instructions and start button
-            entries.append(
-                ConfigEntry(
-                    key="pairing_instructions",
-                    type=ConfigEntryType.LABEL,
-                    label="AirPlay Pairing Required",
-                    description=(
-                        "This device requires pairing before it can be used. "
-                        "Click the button below to start the pairing process."
-                    ),
+            if not self.stream and self.protocol == StreamingProtocol.RAOP:
+                # ensure we have a stream instance to track pairing state
+                from .protocols.raop import RaopStream  # noqa: PLC0415
+
+                self.stream = RaopStream(self)
+            elif not self.stream and self.protocol == StreamingProtocol.AIRPLAY2:
+                # ensure we have a stream instance to track pairing state
+                from .protocols.airplay2 import AirPlay2Stream  # noqa: PLC0415
+
+                self.stream = AirPlay2Stream(self)
+            if self.stream and not self.stream.supports_pairing:
+                # TEMP until ap2 pairing is implemented
+                return [
+                    ConfigEntry(
+                        key="pairing_unsupported",
+                        type=ConfigEntryType.ALERT,
+                        label=(
+                            "This device requires pairing but it is not supported "
+                            "by the current Music Assistant AirPlay implementation."
+                        ),
+                    )
+                ]
+
+            # If pairing was started, show PIN entry
+            if self.stream and self.stream.is_pairing:
+                entries.append(
+                    ConfigEntry(
+                        key=CONF_PAIRING_PIN,
+                        type=ConfigEntryType.STRING,
+                        label="Enter the 4-digit PIN shown on the device",
+                        required=True,
+                    )
                 )
-            )
-            entries.append(
-                ConfigEntry(
-                    key=CONF_ACTION_START_PAIRING,
-                    type=ConfigEntryType.ACTION,
-                    label="Start Pairing",
-                    description="Start the AirPlay pairing process",
-                    action=CONF_ACTION_START_PAIRING,
+                entries.append(
+                    ConfigEntry(
+                        key=CONF_ACTION_FINISH_PAIRING,
+                        type=ConfigEntryType.ACTION,
+                        label="Complete the pairing process with the PIN",
+                        action=CONF_ACTION_FINISH_PAIRING,
+                    )
+                )
+            else:
+                entries.append(
+                    ConfigEntry(
+                        key="pairing_instructions",
+                        type=ConfigEntryType.LABEL,
+                        label=(
+                            "This device requires pairing before it can be used. "
+                            "Click the button below to start the pairing process."
+                        ),
+                    )
+                )
+                entries.append(
+                    ConfigEntry(
+                        key=CONF_ACTION_START_PAIRING,
+                        type=ConfigEntryType.ACTION,
+                        label="Start the AirPlay pairing process",
+                        action=CONF_ACTION_START_PAIRING,
+                    )
                 )
-            )
         else:
             # Show paired status
             entries.append(
                 ConfigEntry(
                     key="pairing_status",
                     type=ConfigEntryType.LABEL,
-                    label="AirPlay Pairing Status",
-                    description="Device is paired and ready to use.",
-                )
-            )
-
-        # If pairing was started, show PIN entry
-        if self.config.get_value("_pairing_in_progress"):
-            entries.append(
-                ConfigEntry(
-                    key=CONF_PAIRING_PIN,
-                    type=ConfigEntryType.STRING,
-                    label="Enter PIN",
-                    description="Enter the 4-digit PIN shown on the device",
-                    required=True,
-                )
-            )
-            entries.append(
-                ConfigEntry(
-                    key=CONF_ACTION_FINISH_PAIRING,
-                    type=ConfigEntryType.ACTION,
-                    label="Finish Pairing",
-                    description="Complete the pairing process with the PIN",
-                    action=CONF_ACTION_FINISH_PAIRING,
+                    label="Device is paired and ready to use.",
                 )
             )
 
@@ -375,7 +346,8 @@ class AirPlayPlayer(Player):
                 key=CONF_AP_CREDENTIALS,
                 type=ConfigEntryType.SECURE_STRING,
                 label="AirPlay Credentials",
-                default_value=None,
+                default_value=credentials,
+                value=credentials,
                 required=False,
                 hidden=True,
             )
@@ -386,21 +358,28 @@ class AirPlayPlayer(Player):
     async def _handle_pairing_action(
         self, action: str, values: dict[str, ConfigValueType] | None
     ) -> None:
-        """Handle pairing actions using cliraop.
+        """Handle pairing actions using the configured protocol."""
+        if not self.stream and self.protocol == StreamingProtocol.RAOP:
+            # ensure we have a stream instance to track pairing state
+            from .protocols.raop import RaopStream  # noqa: PLC0415
 
-        TODO: Implement actual cliraop-based pairing.
-        """
+            self.stream = RaopStream(self)
+        elif not self.stream and self.protocol == StreamingProtocol.AIRPLAY2:
+            # ensure we have a stream instance to track pairing state
+            from .protocols.airplay2 import AirPlay2Stream  # noqa: PLC0415
+
+            self.stream = AirPlay2Stream(self)
         if action == CONF_ACTION_START_PAIRING:
-            # TODO: Start pairing using cliraop
-            # For now, just set a flag to show the PIN entry
-            self.mass.config.set_raw_player_config_value(
-                self.player_id, "_pairing_in_progress", True
-            )
+            if self.stream and self.stream.is_pairing:
+                self.logger.warning("Pairing process already in progress for %s", self.display_name)
+                return
             self.logger.info("Started AirPlay pairing for %s", self.display_name)
+            if self.stream:
+                await self.stream.start_pairing()
 
         elif action == CONF_ACTION_FINISH_PAIRING:
-            # TODO: Finish pairing using cliraop with the provided PIN
             if not values:
+                # guard
                 return
 
             pin = values.get(CONF_PAIRING_PIN)
@@ -408,19 +387,15 @@ class AirPlayPlayer(Player):
                 self.logger.warning("No PIN provided for pairing")
                 return
 
-            # TODO: Use cliraop to complete pairing with the PIN
-            # For now, just clear the pairing in progress flag
-            self.mass.config.set_raw_player_config_value(
-                self.player_id, "_pairing_in_progress", False
-            )
+            if self.stream:
+                credentials = await self.stream.finish_pairing(pin=str(pin))
+            else:
+                return
 
-            # TODO: Store the actual credentials obtained from cliraop
-            # self.mass.config.set_raw_player_config_value(
-            #     self.player_id, CONF_AP_CREDENTIALS, credentials_from_cliraop
-            # )
+            values[CONF_AP_CREDENTIALS] = credentials
 
             self.logger.info(
-                "Finished AirPlay pairing for %s (TODO: implement actual pairing)",
+                "Finished AirPlay pairing for %s",
                 self.display_name,
             )
 
@@ -522,7 +497,7 @@ class AirPlayPlayer(Player):
             if self.stream.prevent_playback:
                 # player is in prevent playback mode, we need to stop the stream
                 await self.stop()
-            else:
+            elif self.stream.session:
                 await self.stream.session.replace_stream(audio_source)
                 return
 
@@ -564,7 +539,7 @@ class AirPlayPlayer(Player):
         if player_ids_to_remove:
             if self.player_id in player_ids_to_remove:
                 # dissolve the entire sync group
-                if self.stream and self.stream.running:
+                if self.stream and self.stream.running and self.stream.session:
                     # stop the stream session if it is running
                     await self.stream.session.stop()
                 self._attr_group_members = []
@@ -598,6 +573,7 @@ class AirPlayPlayer(Player):
                 if (
                     child_player_to_add.stream
                     and child_player_to_add.stream.running
+                    and child_player_to_add.stream.session
                     and child_player_to_add.stream.session != stream_session
                 ):
                     await child_player_to_add.stream.session.remove_client(child_player_to_add)
@@ -663,7 +639,7 @@ class AirPlayPlayer(Player):
         await super().on_unload()
         if self.stream:
             # stop the stream session if it is running
-            if self.stream.running:
+            if self.stream.running and self.stream.session:
                 self.mass.create_task(self.stream.session.stop())
             self.stream = None
 
index 113912263a9fac7e669de86dc1e92edf51a4fc51..d2e470c1d953d75b328c9fc77ce35ad72622921c 100644 (file)
@@ -31,25 +31,26 @@ class AirPlayProtocol(ABC):
     with abstract methods for protocol-specific behavior.
     """
 
+    session: AirPlayStreamSession | None = None  # reference to the active stream session (if any)
+
     # the pcm audio format used for streaming to this protocol
     pcm_format = AudioFormat(
         content_type=ContentType.PCM_S16LE, sample_rate=44100, bit_depth=16, channels=2
     )
+    supports_pairing = False  # whether this protocol supports pairing
+    is_pairing: bool = False  # whether this protocol instance is in pairing mode
 
     def __init__(
         self,
-        session: AirPlayStreamSession,
         player: AirPlayPlayer,
     ) -> None:
         """Initialize base AirPlay protocol.
 
         Args:
-            session: The stream session managing this protocol instance
             player: The player to stream to
         """
-        self.session = session
-        self.prov = session.prov
-        self.mass = session.prov.mass
+        self.prov = player.provider
+        self.mass = player.provider.mass
         self.player = player
         # Generate unique ID to prevent race conditions with named pipes
         self.active_remote_id: str = str(randint(1000, 8000))
@@ -95,6 +96,14 @@ class AirPlayProtocol(ABC):
             skip: Number of seconds to skip (for late joiners)
         """
 
+    async def start_pairing(self) -> None:
+        """Start pairing process for this protocol (if supported)."""
+        raise NotImplementedError("Pairing not implemented for this protocol")
+
+    async def finish_pairing(self, pin: str) -> str:
+        """Finish pairing process with given PIN (if supported)."""
+        raise NotImplementedError("Pairing not implemented for this protocol")
+
     async def _open_pipes(self) -> None:
         """Open both named pipes in non-blocking mode for async I/O."""
         # Open audio pipe with buffer size optimization
index f08c23a67831e6ddccd5bc18f6705a61cc4533e2..4a775fd90413f9c82dccfe864d15a7a4546e5d4b 100644 (file)
@@ -4,6 +4,7 @@ from __future__ import annotations
 
 import asyncio
 import logging
+from typing import TYPE_CHECKING, cast
 
 from music_assistant_models.enums import PlaybackState
 from music_assistant_models.errors import PlayerCommandFailed
@@ -12,6 +13,7 @@ from music_assistant.constants import CONF_SYNC_ADJUST, VERBOSE_LOG_LEVEL
 from music_assistant.helpers.process import AsyncProcess, check_output
 from music_assistant.providers.airplay.constants import (
     CONF_ALAC_ENCODE,
+    CONF_AP_CREDENTIALS,
     CONF_ENCRYPTION,
     CONF_PASSWORD,
     CONF_READ_AHEAD_BUFFER,
@@ -20,6 +22,9 @@ from music_assistant.providers.airplay.helpers import get_cli_binary
 
 from ._protocol import AirPlayProtocol
 
+if TYPE_CHECKING:
+    from music_assistant.providers.airplay.provider import AirPlayProvider
+
 
 class RaopStream(AirPlayProtocol):
     """
@@ -31,6 +36,7 @@ class RaopStream(AirPlayProtocol):
     and we can send some interactive commands using a named pipe.
     """
 
+    supports_pairing = True
     _stderr_reader_task: asyncio.Task[None] | None = None
 
     @property
@@ -77,30 +83,9 @@ class RaopStream(AirPlayProtocol):
             player_id, CONF_PASSWORD, None
         ):
             extra_args += ["-password", str(device_password)]
-        # Add AirPlay credentials from pyatv pairing if available (for Apple devices)
-        # if raop_credentials := self.player.config.get_value(CONF_AP_CREDENTIALS):
-        #     # pyatv AirPlay credentials are in format "identifier:secret_key:other:data"
-        #     # cliraop expects just the secret_key (2nd part, 64-char hex string) for -secret
-        #     parts = str(raop_credentials).split(":")
-        #     if len(parts) >= 2:
-        #         # Take the second part (index 1) as the secret key
-        #         secret_key = parts[1]
-        #         self.prov.logger.debug(
-        #             "Using AirPlay credentials for %s: id=%s, secret_len=%d, parts=%d",
-        #             self.player.player_id,
-        #             parts[0],
-        #             len(secret_key),
-        #             len(parts),
-        #         )
-        #         extra_args += ["-secret", secret_key]
-        #     else:
-        #         # Fallback: assume it's already just the key
-        #         self.prov.logger.debug(
-        #             "Using AirPlay credentials for %s: single value, length=%d",
-        #             self.player.player_id,
-        #             len(str(raop_credentials)),
-        #         )
-        #         extra_args += ["-secret", str(raop_credentials)]
+        # Add AirPlay credentials from pairing if available (for Apple devices)
+        if ap_credentials := self.player.config.get_value(CONF_AP_CREDENTIALS):
+            extra_args += ["-secret", str(ap_credentials)]
         if self.prov.logger.isEnabledFor(logging.DEBUG):
             extra_args += ["-debug", "5"]
         elif self.prov.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
@@ -127,7 +112,7 @@ class RaopStream(AirPlayProtocol):
             str(self.player.volume_level),
             *extra_args,
             "-dacp",
-            self.prov.dacp_id,
+            cast("AirPlayProvider", self.prov).dacp_id,
             "-activeremote",
             self.active_remote_id,
             "-cmdpipe",
@@ -163,6 +148,57 @@ class RaopStream(AirPlayProtocol):
         # start reading the stderr of the cliraop process from another task
         self._stderr_reader_task = self.mass.create_task(self._stderr_reader())
 
+    async def start_pairing(self) -> None:
+        """Start pairing process for this protocol (if supported)."""
+        assert self.player.discovery_info is not None  # for type checker
+        cli_binary = await get_cli_binary(self.player.protocol)
+        cliraop_args = [
+            cli_binary,
+            "-pair",
+            "-if",
+            self.mass.streams.bind_ip,
+            "-port",
+            str(self.player.discovery_info.port),
+            "-udn",
+            self.player.discovery_info.name,
+            self.player.address,
+            self.audio_named_pipe,
+        ]
+        self.player.logger.debug(
+            "Starting PAIRING with cliraop process for player %s with args: %s",
+            self.player.player_id,
+            cliraop_args,
+        )
+        self._cli_proc = AsyncProcess(cliraop_args, stdin=True, stderr=True, name="cliraop")
+        await self._cli_proc.start()
+        # read up to first 10 lines of stderr to get the initial status
+        for _ in range(10):
+            line = (await self._cli_proc.read_stderr()).decode("utf-8", errors="ignore")
+            self.player.logger.debug(line)
+            if "enter PIN code displayed on " in line:
+                self.is_pairing = True
+                return
+        await self._cli_proc.close()
+        raise PlayerCommandFailed("Pairing failed")
+
+    async def finish_pairing(self, pin: str) -> str:
+        """Finish pairing process with given PIN (if supported)."""
+        if not self.is_pairing:
+            await self.start_pairing()
+        if not self._cli_proc or self._cli_proc.closed:
+            raise PlayerCommandFailed("Pairing process not started")
+
+        self.is_pairing = False
+        _, _stderr = await self._cli_proc.communicate(input=f"{pin}\n".encode(), timeout=10)
+        for line in _stderr.decode().splitlines():
+            self.player.logger.debug(line)
+            for error in ("device did not respond", "can't authentify", "pin failed"):
+                if error in line.lower():
+                    raise PlayerCommandFailed(f"Pairing failed: {error}")
+            if "secret is " in line:
+                return line.split("secret is ")[1].strip()
+        raise PlayerCommandFailed(f"Pairing failed: {_stderr.decode().strip()}")
+
     async def _stderr_reader(self) -> None:
         """Monitor stderr for the running CLIRaop process."""
         player = self.player
index 73cdafa37cdc46ad72a47c4106c0b1f2f8eaff33..f86355c36503168517195b13a281802f5d8c249b 100644 (file)
@@ -300,7 +300,8 @@ class AirPlayProvider(PlayerProvider):
                 # device switched to another source (or is powered off)
                 if stream := player.stream:
                     stream.prevent_playback = True
-                    self.mass.create_task(player.stream.session.remove_client(player))
+                    if stream.session:
+                        self.mass.create_task(stream.session.remove_client(player))
             elif "device-prevent-playback=0" in path:
                 # device reports that its ready for playback again
                 if stream := player.stream:
index 2d1c1ccc50f0a8cb851840a981c12f02afb0be13..433f794e6282aa35436b6e32739c4c7548c0ffa7 100644 (file)
@@ -83,9 +83,12 @@ class AirPlayStreamSession:
 
             # Create appropriate stream type based on protocol
             if airplay_player.protocol == StreamingProtocol.AIRPLAY2:
-                airplay_player.stream = AirPlay2Stream(self, airplay_player)
+                airplay_player.stream = AirPlay2Stream(airplay_player)
             else:
-                airplay_player.stream = RaopStream(self, airplay_player)
+                airplay_player.stream = RaopStream(airplay_player)
+
+            # Link stream session to player stream
+            airplay_player.stream.session = self
 
             await self._start_client_ffmpeg(airplay_player)
 
@@ -159,9 +162,12 @@ class AirPlayStreamSession:
 
         # Create appropriate stream type based on protocol
         if airplay_player.protocol == StreamingProtocol.AIRPLAY2:
-            airplay_player.stream = AirPlay2Stream(self, airplay_player)
+            airplay_player.stream = AirPlay2Stream(airplay_player)
         else:
-            airplay_player.stream = RaopStream(self, airplay_player)
+            airplay_player.stream = RaopStream(airplay_player)
+
+        # Link stream session to player stream
+        airplay_player.stream.session = self
 
         # Snapshot chunks_streamed inside lock to prevent race conditions
         # Keep lock held during stream.start() to ensure player doesn't miss any chunks