"""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
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
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)
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,
_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, ...]:
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)],
},
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
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")
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