From: Marcel van der Veldt Date: Fri, 31 Oct 2025 13:42:56 +0000 (+0100) Subject: Fix pairing support for AirPlay X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=0d5fe34486603dd1248fca162d3385d6a5a15260;p=music-assistant-server.git Fix pairing support for AirPlay --- diff --git a/music_assistant/providers/airplay/bin/cliraop-linux-aarch64 b/music_assistant/providers/airplay/bin/cliraop-linux-aarch64 index 4eae6a13..733bcc4b 100755 Binary files a/music_assistant/providers/airplay/bin/cliraop-linux-aarch64 and b/music_assistant/providers/airplay/bin/cliraop-linux-aarch64 differ diff --git a/music_assistant/providers/airplay/bin/cliraop-linux-x86_64 b/music_assistant/providers/airplay/bin/cliraop-linux-x86_64 index dc6cedb9..ae66ff26 100755 Binary files a/music_assistant/providers/airplay/bin/cliraop-linux-x86_64 and b/music_assistant/providers/airplay/bin/cliraop-linux-x86_64 differ diff --git a/music_assistant/providers/airplay/bin/cliraop-macos-arm64 b/music_assistant/providers/airplay/bin/cliraop-macos-arm64 index 8de2c4cc..62a840db 100755 Binary files a/music_assistant/providers/airplay/bin/cliraop-macos-arm64 and b/music_assistant/providers/airplay/bin/cliraop-macos-arm64 differ diff --git a/music_assistant/providers/airplay/player.py b/music_assistant/providers/airplay/player.py index 40aa721f..60882ce2 100644 --- a/music_assistant/providers/airplay/player.py +++ b/music_assistant/providers/airplay/player.py @@ -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 diff --git a/music_assistant/providers/airplay/protocols/_protocol.py b/music_assistant/providers/airplay/protocols/_protocol.py index 11391226..d2e470c1 100644 --- a/music_assistant/providers/airplay/protocols/_protocol.py +++ b/music_assistant/providers/airplay/protocols/_protocol.py @@ -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 diff --git a/music_assistant/providers/airplay/protocols/raop.py b/music_assistant/providers/airplay/protocols/raop.py index f08c23a6..4a775fd9 100644 --- a/music_assistant/providers/airplay/protocols/raop.py +++ b/music_assistant/providers/airplay/protocols/raop.py @@ -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 diff --git a/music_assistant/providers/airplay/provider.py b/music_assistant/providers/airplay/provider.py index 73cdafa3..f86355c3 100644 --- a/music_assistant/providers/airplay/provider.py +++ b/music_assistant/providers/airplay/provider.py @@ -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: diff --git a/music_assistant/providers/airplay/stream_session.py b/music_assistant/providers/airplay/stream_session.py index 2d1c1ccc..433f794e 100644 --- a/music_assistant/providers/airplay/stream_session.py +++ b/music_assistant/providers/airplay/stream_session.py @@ -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