diff --git a/music_assistant/common/models/provider.py b/music_assistant/common/models/provider.py index 6370e502a..732794434 100644 --- a/music_assistant/common/models/provider.py +++ b/music_assistant/common/models/provider.py @@ -1,6 +1,7 @@ """Models for providers and plugins in the MA ecosystem.""" +from __future__ import annotations -import asyncio +import asyncio # noqa: TCH003 from dataclasses import dataclass, field from typing import Any, TypedDict @@ -8,7 +9,7 @@ from music_assistant.common.helpers.json import load_json_file -from .enums import MediaType, ProviderFeature, ProviderType +from .enums import MediaType, ProviderFeature, ProviderType # noqa: TCH001 @dataclass @@ -49,7 +50,7 @@ class ProviderManifest(DataClassORJSONMixin): icon_svg_dark: str | None = None @classmethod - async def parse(cls: "ProviderManifest", manifest_file: str) -> "ProviderManifest": + async def parse(cls: ProviderManifest, manifest_file: str) -> ProviderManifest: """Parse ProviderManifest from file.""" return await load_json_file(manifest_file, ProviderManifest) diff --git a/music_assistant/server/providers/airplay/__init__.py b/music_assistant/server/providers/airplay/__init__.py index a27387065..98042efd2 100644 --- a/music_assistant/server/providers/airplay/__init__.py +++ b/music_assistant/server/providers/airplay/__init__.py @@ -20,7 +20,7 @@ from zeroconf import ServiceInfo from music_assistant.common.helpers.datetime import utc -from music_assistant.common.helpers.util import get_ip_pton +from music_assistant.common.helpers.util import get_ip_pton, select_free_port from music_assistant.common.models.config_entries import ( CONF_ENTRY_CROSSFADE, CONF_ENTRY_CROSSFADE_DURATION, @@ -472,6 +472,8 @@ class AirplayProvider(PlayerProvider): _discovery_running: bool = False _cliraop_bin: str | None = None _stream_tasks: dict[str, asyncio.Task] + _dacp_server: asyncio.Server = None + _dacp_info: ServiceInfo = None @property def supported_features(self) -> tuple[ProviderFeature, ...]: @@ -484,13 +486,15 @@ async def handle_setup(self) -> None: self._stream_tasks = {} self._cliraop_bin = await self.get_cliraop_binary() self.mass.create_task(self._run_discovery()) - dacp_port = 49831 + dacp_port = await select_free_port(39831, 49831) self.dacp_id = dacp_id = f"{randrange(2 ** 64):X}" self.logger.debug("Starting DACP ActiveRemote %s on port %s", dacp_id, dacp_port) - await asyncio.start_server(self._handle_dacp_request, "0.0.0.0", dacp_port) + self._dacp_server = await asyncio.start_server( + self._handle_dacp_request, "0.0.0.0", dacp_port + ) zeroconf_type = "_dacp._tcp.local." server_id = f"iTunes_Ctrl_{dacp_id}.{zeroconf_type}" - info = ServiceInfo( + self._dacp_info = ServiceInfo( zeroconf_type, name=server_id, addresses=[await get_ip_pton(self.mass.streams.publish_ip)], @@ -503,7 +507,19 @@ async def handle_setup(self) -> None: }, server=f"{socket.gethostname()}.local", ) - await self.mass.zeroconf.async_register_service(info) + await self.mass.zeroconf.async_register_service(self._dacp_info) + + async def unload(self) -> None: + """Handle close/cleanup of the provider.""" + # power off all players (will disconnct and close cliraop) + for player_id in self._atv_players: + await self.cmd_power(player_id, False) + # shutdown DACP server + if self._dacp_server: + self._dacp_server.close() + # shutdown DACP zeroconf service + if self._dacp_info: + await self.mass.zeroconf.async_unregister_service(self._dacp_info) async def _handle_dacp_request( # noqa: PLR0915 self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter diff --git a/music_assistant/server/providers/airplay/bin/cliraop-linux-aarch64 b/music_assistant/server/providers/airplay/bin/cliraop-linux-aarch64 old mode 100755 new mode 100644 index ef4c4eb4a..0bd6d2716 Binary files a/music_assistant/server/providers/airplay/bin/cliraop-linux-aarch64 and b/music_assistant/server/providers/airplay/bin/cliraop-linux-aarch64 differ diff --git a/music_assistant/server/providers/airplay/bin/cliraop-linux-x86_64 b/music_assistant/server/providers/airplay/bin/cliraop-linux-x86_64 old mode 100755 new mode 100644 index ba25f08a6..eefe426a3 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 diff --git a/music_assistant/server/providers/airplay/bin/cliraop-macos-arm64 b/music_assistant/server/providers/airplay/bin/cliraop-macos-arm64 index 4a91d903f..e04e68da9 100755 Binary files a/music_assistant/server/providers/airplay/bin/cliraop-macos-arm64 and b/music_assistant/server/providers/airplay/bin/cliraop-macos-arm64 differ diff --git a/music_assistant/server/server.py b/music_assistant/server/server.py index 0c8080484..43309e610 100644 --- a/music_assistant/server/server.py +++ b/music_assistant/server/server.py @@ -527,7 +527,7 @@ async def load_provider_manifest(provider_domain: str, provider_path: str) -> No if file_str != "manifest.json": continue try: - provider_manifest = await ProviderManifest.parse(file_path) + provider_manifest: ProviderManifest = await ProviderManifest.parse(file_path) # check for icon.svg file if not provider_manifest.icon_svg: icon_path = os.path.join(provider_path, "icon.svg") @@ -538,9 +538,15 @@ async def load_provider_manifest(provider_domain: str, provider_path: str) -> No icon_path = os.path.join(provider_path, "icon_dark.svg") if os.path.isfile(icon_path): provider_manifest.icon_svg_dark = await get_icon_string(icon_path) - # install requirements - for requirement in provider_manifest.requirements: - await install_package(requirement) + # try to load the module + try: + await get_provider_module(provider_manifest.domain) + except ImportError: + # install requirements + for requirement in provider_manifest.requirements: + await install_package(requirement) + # try loading the provider again to be safe + await get_provider_module(provider_manifest.domain) self._provider_manifests[provider_manifest.domain] = provider_manifest LOGGER.debug("Loaded manifest for provider %s", provider_manifest.name) except Exception as exc: # pylint: disable=broad-except