From: hmonteiro <1819451+hmonteiro@users.noreply.github.com> Date: Sat, 21 Feb 2026 07:27:10 +0000 (+0000) Subject: Expand PIN based auth in airplay 2 (#3165) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=8a0a4fae71702d04a13635f1c7c771cee3ac32af;p=music-assistant-server.git Expand PIN based auth in airplay 2 (#3165) * add LG details * Make pin based auth work in other devices * remove reference to apple tv and macos in check * remove unused constant and adjust airplay2 filter * also apply pairing check to raop * add unit test --- diff --git a/music_assistant/providers/airplay/constants.py b/music_assistant/providers/airplay/constants.py index 4d38b064..8c6e32ba 100644 --- a/music_assistant/providers/airplay/constants.py +++ b/music_assistant/providers/airplay/constants.py @@ -80,8 +80,11 @@ AIRPLAY_2_DEFAULT_MODELS = ( # Models that are known to work better with AirPlay 2 protocol instead of RAOP # These use the translated/friendly model names from get_model_info() ("Ubiquiti Inc.", "*"), + ("LG Electronics", "*"), ) +PIN_REQUIRED = 0x8 +LEGACY_PAIRING_BIT = 0x200 BROKEN_AIRPLAY_WARN = ConfigEntry( key="BROKEN_AIRPLAY", type=ConfigEntryType.ALERT, diff --git a/music_assistant/providers/airplay/player.py b/music_assistant/providers/airplay/player.py index 44061715..1e2a9b6f 100644 --- a/music_assistant/providers/airplay/player.py +++ b/music_assistant/providers/airplay/player.py @@ -37,6 +37,8 @@ from .constants import ( CONF_PASSWORD, CONF_RAOP_CREDENTIALS, FALLBACK_VOLUME, + LEGACY_PAIRING_BIT, + PIN_REQUIRED, RAOP_DISCOVERY_TYPE, StreamingProtocol, ) @@ -75,9 +77,9 @@ class AirPlayPlayer(Player): initial_volume: int = FALLBACK_VOLUME, ) -> None: """Initialize AirPlayPlayer.""" - super().__init__(provider, player_id) self.raop_discovery_info = raop_discovery_info self.airplay_discovery_info = airplay_discovery_info + super().__init__(provider, player_id) self.address = address self.stream: RaopStream | AirPlay2Stream | None = None self.last_command_sent = 0.0 @@ -246,17 +248,24 @@ class AirPlayPlayer(Player): return base_entries + def _get_flags(self) -> int: + # Flags are either present via "sf" or "flags. Taken from pyatv.protocols.airplay.utils" + if self.airplay_discovery_info: + properties = self.airplay_discovery_info.properties + elif self.raop_discovery_info: + properties = self.raop_discovery_info.properties + else: + return 0 + + flags = properties.get(b"sf") or properties.get(b"flags") or "0x0" + return int(flags, 16) + def _requires_pairing(self) -> bool: - """Check if this device requires pairing (Apple TV or macOS).""" - if self.device_info.manufacturer.lower() != "apple": - return False - - model = self.device_info.model - # Apple TV devices - if "appletv" in model.lower() or "apple tv" in model.lower(): - return True - # Mac devices (including iMac, MacBook, Mac mini, Mac Pro, Mac Studio) - return model.startswith(("Mac", "iMac")) + """Check if this device requires pairing. + + Adapted from pyatv.protocols.airplay.utils.get_pairing_requirement. + """ + return bool(self._get_flags() & (LEGACY_PAIRING_BIT | PIN_REQUIRED)) def _get_credentials_key(self, protocol: StreamingProtocol) -> str: """Get the config key for credentials for given protocol.""" diff --git a/tests/providers/airplay/__init__.py b/tests/providers/airplay/__init__.py new file mode 100644 index 00000000..09256c3d --- /dev/null +++ b/tests/providers/airplay/__init__.py @@ -0,0 +1 @@ +"""Tests for AirPlay provider.""" diff --git a/tests/providers/airplay/test_player.py b/tests/providers/airplay/test_player.py new file mode 100644 index 00000000..c0a72a2d --- /dev/null +++ b/tests/providers/airplay/test_player.py @@ -0,0 +1,52 @@ +"""Unit tests for AirPlay player.""" + +from unittest.mock import MagicMock + +import pytest + +from music_assistant.providers.airplay.player import AirPlayPlayer + + +@pytest.mark.parametrize( + ("aiplay_properties", "raop_properties", "expected"), + [ + ({b"flags": b"0x200"}, None, True), + ({b"sf": b"0x201"}, None, True), + ({b"flags": b"0x4"}, None, False), + ({b"sf": b"0x8"}, None, True), + ({b"flags": b"0x9"}, None, True), + (None, {b"flags": "0x200"}, True), + (None, {b"sf": b"0x201"}, True), + (None, {b"flags": b"0x4"}, False), + (None, {b"sf": b"0x8"}, True), + (None, {b"flags": b"0x9"}, True), + ({}, {}, False), + ], +) +def test_requires_pairing( + aiplay_properties: dict[bytes, bytes] | None, + raop_properties: dict[bytes, bytes] | None, + expected: bool, +) -> None: + """Test the _requires_pairing method of AirPlayPlayer.""" + if aiplay_properties is not None: + aiplay_discovery_info = MagicMock() + aiplay_discovery_info.properties = aiplay_properties + else: + aiplay_discovery_info = None + if raop_properties is not None: + raop_discovery_info = MagicMock() + raop_discovery_info.properties = raop_properties + else: + raop_discovery_info = None + player = AirPlayPlayer( + provider=MagicMock(), + player_id="test_player", + display_name="Test Player", + address="127.0.0.1", + manufacturer="Test Manufacturer", + model="Test Model", + raop_discovery_info=raop_discovery_info, + airplay_discovery_info=aiplay_discovery_info, + ) + assert player._requires_pairing() == expected