Expand PIN based auth in airplay 2 (#3165)
authorhmonteiro <1819451+hmonteiro@users.noreply.github.com>
Sat, 21 Feb 2026 07:27:10 +0000 (07:27 +0000)
committerGitHub <noreply@github.com>
Sat, 21 Feb 2026 07:27:10 +0000 (08:27 +0100)
* 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

music_assistant/providers/airplay/constants.py
music_assistant/providers/airplay/player.py
tests/providers/airplay/__init__.py [new file with mode: 0644]
tests/providers/airplay/test_player.py [new file with mode: 0644]

index 4d38b064d6e512210f93bdc2c611bceb26f6a18d..8c6e32ba9168bd4e148b93fa5b5e956e599296d7 100644 (file)
@@ -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,
index 44061715ef5c97cad9ca0c3d39c98373d5a5bd46..1e2a9b6fca1616f06f412038d7a74f943e542c00 100644 (file)
@@ -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 (file)
index 0000000..09256c3
--- /dev/null
@@ -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 (file)
index 0000000..c0a72a2
--- /dev/null
@@ -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