airplay enhancements
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Tue, 20 Feb 2024 00:00:32 +0000 (01:00 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Tue, 20 Feb 2024 00:00:32 +0000 (01:00 +0100)
music_assistant/server/providers/airplay/__init__.py
music_assistant/server/providers/airplay/bin/cliraop-linux-aarch64
music_assistant/server/providers/airplay/bin/cliraop-linux-x86_64
music_assistant/server/providers/airplay/bin/cliraop-macos-arm64
music_assistant/server/providers/chromecast/__init__.py
music_assistant/server/server.py

index 2773a40c01d1e539551ded281ab792ecb2871ac8..1e652c51ebb3a167723607fdce3740c0278ae1ad 100644 (file)
@@ -11,14 +11,14 @@ from collections.abc import AsyncGenerator
 from random import randint, randrange
 from typing import TYPE_CHECKING, cast
 
-from pyatv import connect, exceptions, interface, scan
+from pyatv import connect, interface, scan
 from pyatv.conf import AppleTV as ATVConf
 from pyatv.const import DeviceModel, DeviceState, PowerState, Protocol
 from pyatv.convert import model_str
 from pyatv.interface import AppleTV as AppleTVInterface
 from pyatv.interface import DeviceListener
 from pyatv.protocols.raop import RaopStream
-from zeroconf import ServiceInfo
+from zeroconf.asyncio import AsyncServiceInfo
 
 from music_assistant.common.helpers.datetime import utc
 from music_assistant.common.helpers.util import get_ip_pton, select_free_port
@@ -174,7 +174,6 @@ class AirPlayPlayer(DeviceListener):
         self.discovery_info = discovery_info
         self.mass = mass
         self.atv: AppleTVInterface | None = None
-        self.is_on = False
         self.connected = False
         self._connection_attempts = 0
         self._connection_was_lost = False
@@ -208,15 +207,18 @@ class AirPlayPlayer(DeviceListener):
         if self.atv:
             self.atv.close()
             self.atv = None
-        self._start_connect_loop()
 
     async def connect(self):
         """Connect to device."""
-        self.is_on = True
         if self.connected:
             return
-        self._start_connect_loop()
-        await asyncio.sleep(2)
+        try:
+            await self._connect(self.discovery_info)
+        except Exception:
+            # retry with scanning for the device
+            if conf := await self._scan():
+                await self._connect(conf)
+            raise
 
     async def disconnect(self):
         """Disconnect from device."""
@@ -260,56 +262,6 @@ class AirPlayPlayer(DeviceListener):
         self.logger.debug("sending command %s", command)
         await self.mass.create_task(send_data)
 
-    def _start_connect_loop(self):
-        """Start background connect loop to device."""
-        if not self._task and self.atv is None and self.is_on:
-            self._task = asyncio.create_task(self._connect_loop())
-        else:
-            self.logger.debug("Not starting connect loop (%s, %s)", self.atv is None, self.is_on)
-
-    async def connect_once(self) -> None:
-        """Try to connect once."""
-        try:
-            if conf := await self._scan():
-                await self._connect(conf)
-        except exceptions.AuthenticationError:
-            await self.disconnect()
-            self.logger.exception(
-                "Authentication failed for %s, try reconfiguring device",
-                self.discovery_info.name,
-            )
-            return
-        except asyncio.CancelledError:
-            pass
-        except Exception:  # pylint: disable=broad-except
-            self.logger.exception("Failed to connect")
-            self.atv = None
-
-    async def _connect_loop(self):
-        """Connect loop background task function."""
-        self.logger.debug("Starting connect loop")
-
-        # Try to find device and connect as long as the user has said that
-        # we are allowed to connect and we are not already connected.
-        while self.is_on and self.atv is None:
-            await self.connect_once()
-            if self.atv is not None:
-                break
-            self._connection_attempts += 1
-            backoff = min(
-                max(
-                    BACKOFF_TIME_LOWER_LIMIT,
-                    randrange(2**self._connection_attempts),
-                ),
-                BACKOFF_TIME_UPPER_LIMIT,
-            )
-
-            self.logger.debug("Reconnecting in %d seconds", backoff)
-            await asyncio.sleep(backoff)
-
-        self.logger.debug("Connect loop ended")
-        self._task = None
-
     async def _scan(self) -> ATVConf | None:
         """Try to find device by scanning for it."""
         address: str = self.discovery_info.address
@@ -318,6 +270,7 @@ class AirPlayPlayer(DeviceListener):
         atvs = await scan(
             self.mass.loop,
             identifier=self.discovery_info.identifier,
+            aiozc=self.mass.aiozc,
             hosts=[address],
         )
         if atvs:
@@ -328,8 +281,6 @@ class AirPlayPlayer(DeviceListener):
             self.discovery_info.name,
             address,
         )
-        # We no longer multicast scan for the device since as soon as async_step_zeroconf runs,
-        # it will update the address and reload the config entry when the device is found.
         return None
 
     async def _connect(self, conf: ATVConf) -> None:
@@ -357,7 +308,6 @@ class AirPlayPlayer(DeviceListener):
 
         self.logger.debug("Connecting to device %s", name)
         session = self.mass.http_session
-
         self.atv = await connect(conf, self.mass.loop, session=session)
         self.connected = True
         self.atv.power.listener = self
@@ -377,7 +327,7 @@ class AirPlayPlayer(DeviceListener):
         self._connection_attempts = 0
         if self._connection_was_lost:
             self.logger.info(
-                'Connection was re-established to device "%s"',
+                'Connection was (re)established to device "%s"',
                 name,
             )
             self._connection_was_lost = False
@@ -442,7 +392,7 @@ class AirPlayPlayer(DeviceListener):
         """Update the player attributes."""
         mass_player = self.mass.players.get(self.player_id)
         mass_player.volume_level = int(self.atv.audio.volume)
-        mass_player.powered = self.is_on
+        mass_player.powered = self.connected or self.cliraop_proc and not self.cliraop_proc.closed
         if self.cliraop_proc and not self.cliraop_proc.closed:
             mass_player.state = self.optimistic_state
             # NOTE: alapsed time is pushed from cliraop
@@ -485,7 +435,7 @@ class AirplayProvider(PlayerProvider):
     _cliraop_bin: str | None = None
     _stream_tasks: dict[str, asyncio.Task]
     _dacp_server: asyncio.Server = None
-    _dacp_info: ServiceInfo = None
+    _dacp_info: AsyncServiceInfo = None
 
     @property
     def supported_features(self) -> tuple[ProviderFeature, ...]:
@@ -507,7 +457,7 @@ class AirplayProvider(PlayerProvider):
         )
         zeroconf_type = "_dacp._tcp.local."
         server_id = f"iTunes_Ctrl_{dacp_id}.{zeroconf_type}"
-        self._dacp_info = ServiceInfo(
+        self._dacp_info = AsyncServiceInfo(
             zeroconf_type,
             name=server_id,
             addresses=[await get_ip_pton(self.mass.streams.publish_ip)],
@@ -520,7 +470,7 @@ class AirplayProvider(PlayerProvider):
             },
             server=f"{socket.gethostname()}.local",
         )
-        await self.mass.zeroconf.async_register_service(self._dacp_info)
+        await self.mass.aiozc.async_register_service(self._dacp_info)
 
     async def loaded_in_mass(self) -> None:
         """Call after the provider has been loaded."""
@@ -536,7 +486,7 @@ class AirplayProvider(PlayerProvider):
             self._dacp_server.close()
         # shutdown DACP zeroconf service
         if self._dacp_info:
-            await self.mass.zeroconf.async_unregister_service(self._dacp_info)
+            await self.mass.aiozc.async_unregister_service(self._dacp_info)
 
     async def _handle_dacp_request(  # noqa: PLR0915
         self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
@@ -693,6 +643,7 @@ class AirplayProvider(PlayerProvider):
         # stop existing streams first
         await self.cmd_stop(player_id)
         # power on player if needed
+        await self.cmd_power(player_id, True)
         # start streaming the queue (pcm) audio in a background task
         queue = self.mass.player_queues.get_active_queue(player_id)
         self._stream_tasks[player_id] = asyncio.create_task(
@@ -766,7 +717,7 @@ class AirplayProvider(PlayerProvider):
         async with asyncio.TaskGroup() as tg:
             for atv_player in self._get_sync_clients(player_id):
                 if not atv_player.atv:
-                    # just in case...
+                    # should not be possible, but just in case...
                     await atv_player.connect()
                 tg.create_task(self._init_cliraop(atv_player, ntp))
         prev_metadata_checksum: str = ""
@@ -973,7 +924,7 @@ class AirplayProvider(PlayerProvider):
                 sync_clients.append(client)
         return sync_clients
 
-    async def _init_cliraop(self, atv_player: AirPlayPlayer, ntp: int) -> None:
+    async def _init_cliraop(self, atv_player: AirPlayPlayer, ntp: int) -> None:  # noqa: PLR0915
         """Initiatlize CLIRaop process for a player."""
         stream: RaopStream | None = next(
             (x for x in atv_player.atv.stream.instances if isinstance(x, RaopStream)), None
@@ -1006,6 +957,8 @@ class AirplayProvider(PlayerProvider):
             # if we reach this point, the process exited
             if cliraop_proc._proc.returncode is not None:
                 cliraop_proc.closed = True
+            atv_player.optimistic_state = PlayerState.IDLE
+            atv_player.update_attributes()
             logger.debug(
                 "CLIRaop process stopped with errorcode %s",
                 cliraop_proc._proc.returncode,
@@ -1019,7 +972,7 @@ class AirplayProvider(PlayerProvider):
         if self.mass.config.get_raw_player_config_value(
             atv_player.player_id, CONF_ENCRYPTION, False
         ):
-            extra_args += ["-et"]
+            extra_args += ["-u"]
         if self.mass.config.get_raw_player_config_value(
             atv_player.player_id, CONF_ALAC_ENCODE, False
         ):
index ea1044350137dd507bf2f97d3fb4cfc47b3c511c..e2713b51280fa65c8e37be96df52c58257432679 100755 (executable)
Binary files a/music_assistant/server/providers/airplay/bin/cliraop-linux-aarch64 and b/music_assistant/server/providers/airplay/bin/cliraop-linux-aarch64 differ
index c6a24fb1f1fc536efe941915578639be2c8c670b..cf1ff1d9a2a7337a2167a34d21faf92d09b2a1c6 100755 (executable)
Binary files a/music_assistant/server/providers/airplay/bin/cliraop-linux-x86_64 and b/music_assistant/server/providers/airplay/bin/cliraop-linux-x86_64 differ
index 09a31d9711b4129144550484d62b36d5558ebc10..b955a34d1a47c2a6c2f5a6603899d1e74e42f3dc 100755 (executable)
Binary files a/music_assistant/server/providers/airplay/bin/cliraop-macos-arm64 and b/music_assistant/server/providers/airplay/bin/cliraop-macos-arm64 differ
index 4e3d6a6db418351ee0daeb3cc4e39b9baef2e2b5..e4cdbc557d36acce1b7dc694e50f56a0dcf7599f 100644 (file)
@@ -137,7 +137,7 @@ class ChromecastProvider(PlayerProvider):
         self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
     ) -> None:
         """Handle async initialization of the provider."""
-        super.__init__(mass, manifest, config)
+        super().__init__(mass, manifest, config)
         self._discover_lock = threading.Lock()
         self.castplayers = {}
         self.mz_mgr = MultizoneManager()
@@ -147,7 +147,7 @@ class ChromecastProvider(PlayerProvider):
                 remove_callback=self._on_chromecast_removed,
                 update_callback=self._on_chromecast_discovered,
             ),
-            self.mass.zeroconf,
+            self.mass.aiozc.zeroconf,
         )
         # silence pychromecast logging
         logging.getLogger("pychromecast").setLevel(self.logger.level + 10)
@@ -395,7 +395,7 @@ class ChromecastProvider(PlayerProvider):
                 return
             # new player discovered
             cast_info = ChromecastInfo.from_cast_info(disc_info)
-            cast_info.fill_out_missing_chromecast_info(self.mass.zeroconf)
+            cast_info.fill_out_missing_chromecast_info(self.mass.aiozc.zeroconf)
             if cast_info.is_dynamic_group:
                 self.logger.debug("Discovered a dynamic cast group which will be ignored.")
                 return
@@ -418,7 +418,7 @@ class ChromecastProvider(PlayerProvider):
                 cast_info=cast_info,
                 cc=pychromecast.get_chromecast_from_cast_info(
                     disc_info,
-                    self.mass.zeroconf,
+                    self.mass.aiozc.zeroconf,
                 ),
                 player=Player(
                     player_id=player_id,
index 137b179e075acd513cfe82c894efe642ae0a16f3..48300dad9b42d7ddc8d37f097dfa52c057910590 100644 (file)
@@ -12,7 +12,8 @@ from uuid import uuid4
 
 import aiofiles
 from aiohttp import ClientSession, TCPConnector
-from zeroconf import InterfaceChoice, NonUniqueNameException, ServiceInfo, Zeroconf
+from zeroconf import IPVersion, NonUniqueNameException, ServiceStateChange, Zeroconf
+from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
 
 from music_assistant.common.helpers.util import get_ip_pton
 from music_assistant.common.models.api import ServerInfoMessage
@@ -76,7 +77,8 @@ class MusicAssistant:
 
     loop: asyncio.AbstractEventLoop
     http_session: ClientSession
-    zeroconf: Zeroconf
+    aiozc: AsyncZeroconf
+    aiobrowser: AsyncServiceBrowser
     config: ConfigController
     webserver: WebserverController
     cache: CacheController
@@ -105,7 +107,13 @@ class MusicAssistant:
         self.running_as_hass_addon = await is_hass_supervisor()
         self.version = await get_package_version("music_assistant")
         # create shared zeroconf instance
-        self.zeroconf = Zeroconf(interfaces=InterfaceChoice.All)
+        # TODO: enumerate interfaces and enable IPv6 support
+        self.aiozc = AsyncZeroconf(ip_version=IPVersion.V4Only)
+        # self.aiobrowser = AsyncServiceBrowser(
+        #     self.aiozc.zeroconf,
+        #     [],
+        #     handlers=[self._on_mdns_service_state_change],
+        # )
         # create shared aiohttp ClientSession
         self.http_session = ClientSession(
             loop=self.loop,
@@ -568,8 +576,9 @@ class MusicAssistant:
         """Make this Music Assistant instance discoverable on the network."""
         zeroconf_type = "_mass._tcp.local."
         server_id = self.server_id
-
-        info = ServiceInfo(
+        # register MA on mdns to be discovered
+        LOGGER.debug("Starting Zeroconf broadcast...")
+        info = AsyncServiceInfo(
             zeroconf_type,
             name=f"{server_id}.{zeroconf_type}",
             addresses=[await get_ip_pton(self.webserver.publish_ip)],
@@ -577,19 +586,27 @@ class MusicAssistant:
             properties=self.get_server_info().to_dict(),
             server="mass.local.",
         )
-        LOGGER.debug("Starting Zeroconf broadcast...")
         try:
             existing = getattr(self, "mass_zc_service_set", None)
             if existing:
-                await self.zeroconf.async_update_service(info)
+                await self.aiozc.async_update_service(info)
             else:
-                await self.zeroconf.async_register_service(info)
+                await self.aiozc.async_register_service(info)
             self.mass_zc_service_set = True
         except NonUniqueNameException:
             LOGGER.exception(
                 "Music Assistant instance with identical name present in the local network!"
             )
 
+    def _on_mdns_service_state_change(
+        self,
+        zeroconf: Zeroconf,  # pylint: disable=unused-argument
+        service_type: str,
+        name: str,
+        state_change: ServiceStateChange,
+    ) -> None:
+        """Handle MDNS service state callback."""
+
     async def __aenter__(self) -> Self:
         """Return Context manager."""
         await self.start()