Enhancement: Migrate manual discovery IP's to dedicated config entry
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 6 Mar 2025 18:15:51 +0000 (19:15 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 6 Mar 2025 18:15:51 +0000 (19:15 +0100)
music_assistant/constants.py
music_assistant/controllers/config.py
music_assistant/providers/chromecast/__init__.py
music_assistant/providers/sonos/__init__.py
music_assistant/providers/sonos/provider.py

index a553e103987db4fa9830347953de50d5acdb0182..53f5b571b62d0f576729cf1184078daf41bb3c54 100644 (file)
@@ -86,6 +86,7 @@ CONF_MUTE_CONTROL: Final[str] = "mute_control"
 CONF_OUTPUT_CODEC: Final[str] = "output_codec"
 CONF_ALLOW_MEMORY_CACHE: Final[str] = "allow_memory_cache"
 
+
 # config default values
 DEFAULT_HOST: Final[str] = "0.0.0.0"
 DEFAULT_PORT: Final[int] = 8095
@@ -535,6 +536,27 @@ CONF_ENTRY_WARN_PREVIEW = ConfigEntry(
     required=False,
 )
 
+CONF_ENTRY_MANUAL_DISCOVERY_IPS = ConfigEntry(
+    key="manual_discovery_ip_addresses",
+    type=ConfigEntryType.STRING,
+    label="Manual IP addresses for discovery",
+    description="In normal circumstances, "
+    "Music Assistant will automatically discover all players on the network. "
+    "using multicast discovery on the (L2) local network, such as mDNS or UPNP.\n\n"
+    "In case of special network setups or when you run into issues where "
+    "one or more players are not discovered, you can manually add the IP "
+    "addresses of the players here. \n\n"
+    "Note that this setting is not recommended for normal use and should only be used "
+    "if you know what you are doing. Also, if players are not on the same subnet as"
+    "the Music Assistant server, you may run into issues with streaming. "
+    "In that case always ensure that the players can reach the server on the network "
+    "and double check the base URL configuration of the Stream server in the settings.",
+    category="advanced",
+    default_value=[],
+    required=False,
+    multi_value=True,
+)
+
 
 def create_sample_rates_config_entry(
     supported_sample_rates: list[int] | None = None,
index 6cbee71ea8ccd8a6bcd8d31e8207cc5eaa244316..18a1a07c12335c7a75b60f985e9f5d5fc4879b75 100644 (file)
@@ -808,6 +808,15 @@ class ConfigController:
                 self._data[CONF_PROVIDERS].pop(instance_id, None)
                 LOGGER.warning("Removed corrupt provider configuration: %s", instance_id)
                 changed = True
+        # migrate manual_ips to new format
+        for instance_id, provider_config in list(self._data.get(CONF_PROVIDERS, {}).items()):
+            if not (values := provider_config.get("values")):
+                continue
+            if not (ips := values.get("ips")):
+                continue
+            values["manual_discovery_ip_addresses"] = ips.split(",")
+            del values["ips"]
+            changed = True
         # migrate sample_rates config entry
         for player_id, player_config in list(self._data.get(CONF_PLAYERS, {}).items()):
             if not (values := player_config.get("values")):
index c472e7a4d14d20b0dce0fe351a18afa8e07da2bc..36237c507849e0ab64c21933ebd0c92785f75223 100644 (file)
@@ -32,6 +32,7 @@ from music_assistant.constants import (
     BASE_PLAYER_CONFIG_ENTRIES,
     CONF_ENTRY_CROSSFADE_DURATION,
     CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED,
+    CONF_ENTRY_MANUAL_DISCOVERY_IPS,
     CONF_ENTRY_OUTPUT_CODEC,
     CONF_MUTE_CONTROL,
     CONF_PLAYERS,
@@ -116,7 +117,7 @@ async def get_config_entries(
     values: the (intermediate) raw values for config entries sent with the action.
     """
     # ruff: noqa: ARG001
-    return ()  # we do not have any config entries (yet)
+    return (CONF_ENTRY_MANUAL_DISCOVERY_IPS,)
 
 
 @dataclass
@@ -150,6 +151,8 @@ class ChromecastProvider(PlayerProvider):
         self._discover_lock = threading.Lock()
         self.castplayers = {}
         self.mz_mgr = MultizoneManager()
+        # Handle config option for manual IP's
+        manual_ip_config: list[str] = config.get_value(CONF_ENTRY_MANUAL_DISCOVERY_IPS.key)
         self.browser = CastBrowser(
             SimpleCastListener(
                 add_callback=self._on_chromecast_discovered,
@@ -157,6 +160,7 @@ class ChromecastProvider(PlayerProvider):
                 update_callback=self._on_chromecast_discovered,
             ),
             self.mass.aiozc.zeroconf,
+            known_hosts=manual_ip_config,
         )
         # set-up pychromecast logging
         if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
@@ -166,7 +170,6 @@ class ChromecastProvider(PlayerProvider):
 
     async def discover_players(self) -> None:
         """Discover Cast players on the network."""
-        # start discovery in executor
         await self.mass.loop.run_in_executor(None, self.browser.start_discovery)
 
     async def unload(self, is_removed: bool = False) -> None:
index 0ccfa50c1d0be7850750d952dc0c238eb6fff6a2..31eed7c6311f287feebd80860846a6c021eb73ce 100644 (file)
@@ -10,14 +10,12 @@ from __future__ import annotations
 import logging
 from typing import TYPE_CHECKING
 
-from music_assistant_models.config_entries import ConfigEntry, ConfigEntryType
+from music_assistant.constants import CONF_ENTRY_MANUAL_DISCOVERY_IPS, VERBOSE_LOG_LEVEL
 
-from music_assistant.constants import VERBOSE_LOG_LEVEL
-
-from .provider import CONF_IPS, SonosPlayerProvider
+from .provider import SonosPlayerProvider
 
 if TYPE_CHECKING:
-    from music_assistant_models.config_entries import ConfigValueType, ProviderConfig
+    from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig
     from music_assistant_models.provider import ProviderManifest
 
     from music_assistant import MusicAssistant
@@ -51,20 +49,4 @@ async def get_config_entries(
     values: the (intermediate) raw values for config entries sent with the action.
     """
     # ruff: noqa: ARG001
-    return (
-        ConfigEntry(
-            key=CONF_IPS,
-            type=ConfigEntryType.STRING,
-            label="IP addresses (ADVANCED, NOT SUPPORTED)",
-            description="Additional fixed IP addresses for speakers. "
-            "Should be formatted as a comma separated list of IP addresses "
-            "(e.g. '10.0.0.42, 10.0.0.45').\n"
-            "Invalid addresses may result in the Sonos provider "
-            "becoming unresponsive and server crashes.\n"
-            "Bidirectional unicast communication to and between all IPs is required.\n"
-            "NOT SUPPORTED, USE AT YOUR OWN RISK",
-            category="advanced",
-            default_value=None,
-            required=False,
-        ),
-    )
+    return (CONF_ENTRY_MANUAL_DISCOVERY_IPS,)
index 985aee11bbcc2cb15ada3e5eeb9cad56030b181b..004e26832614dc9645e5911f4c369b050f1ce4c5 100644 (file)
@@ -25,6 +25,7 @@ from zeroconf import ServiceStateChange
 from music_assistant.constants import (
     CONF_ENTRY_CROSSFADE,
     CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED,
+    CONF_ENTRY_MANUAL_DISCOVERY_IPS,
     CONF_ENTRY_OUTPUT_CODEC,
     MASS_LOGO_ONLINE,
     VERBOSE_LOG_LEVEL,
@@ -41,8 +42,6 @@ if TYPE_CHECKING:
     from music_assistant_models.queue_item import QueueItem
     from zeroconf.asyncio import AsyncServiceInfo
 
-CONF_IPS = "ips"
-
 
 class SonosPlayerProvider(PlayerProvider):
     """Sonos Player provider."""
@@ -73,30 +72,22 @@ class SonosPlayerProvider(PlayerProvider):
     async def loaded_in_mass(self) -> None:
         """Call after the provider has been loaded."""
         await super().loaded_in_mass()
-
-        manual_ip_config: str | None
-        # Handle config option for manual IP's (comma separated list)
-        if (manual_ip_config := self.config.get_value(CONF_IPS)) is not None:
-            ips = manual_ip_config.split(",")
-            for raw_ip in ips:
-                # strip to ignore whitespace
-                # (e.g. '10.0.0.42, 10.0.0.43' -> ('10.0.0.42', ' 10.0.0.43'))
-                ip = raw_ip.strip()
-                if ip == "":
-                    continue
-                try:
-                    # get discovery info from SONOS speaker so we can provide an ID & other info
-                    discovery_info = await get_discovery_info(self.mass.http_session, ip)
-                except ClientError as err:
-                    self.logger.debug(
-                        "Ignoring %s (manual IP) as it is not reachable: %s", ip, str(err)
-                    )
-                    continue
-                player_id = discovery_info["device"]["id"]
-                self.sonos_players[player_id] = sonos_player = SonosPlayer(
-                    self, player_id, discovery_info=discovery_info, ip_address=ip
+        # Handle config option for manual IP's
+        manual_ip_config: list[str] = self.config.get_value(CONF_ENTRY_MANUAL_DISCOVERY_IPS.key)
+        for ip_address in manual_ip_config:
+            try:
+                # get discovery info from SONOS speaker so we can provide an ID & other info
+                discovery_info = await get_discovery_info(self.mass.http_session, ip_address)
+            except ClientError as err:
+                self.logger.debug(
+                    "Ignoring %s (manual IP) as it is not reachable: %s", ip_address, str(err)
                 )
-                await sonos_player.setup()
+                continue
+            player_id = discovery_info["device"]["id"]
+            self.sonos_players[player_id] = sonos_player = SonosPlayer(
+                self, player_id, discovery_info=discovery_info, ip_address=ip_address
+            )
+            await sonos_player.setup()
 
     async def unload(self, is_removed: bool = False) -> None:
         """Handle close/cleanup of the provider."""