Enable IPv6 support for zeroconf, stream server and AirPlay DACP (#3086)
authorFirdavs Murodov <firdavs.murodov@gmail.com>
Fri, 6 Feb 2026 09:34:22 +0000 (10:34 +0100)
committerGitHub <noreply@github.com>
Fri, 6 Feb 2026 09:34:22 +0000 (10:34 +0100)
music_assistant/controllers/streams/streams_controller.py
music_assistant/controllers/webserver/controller.py
music_assistant/helpers/util.py
music_assistant/mass.py
music_assistant/providers/airplay/provider.py

index eae753e3dce46f2836e061f6d296b6b668398eed..4cf05734ca1418651cb59600433893219e32cf86 100644 (file)
@@ -80,6 +80,7 @@ from music_assistant.helpers.ffmpeg import LOGGER as FFMPEG_LOGGER
 from music_assistant.helpers.ffmpeg import check_ffmpeg_version, get_ffmpeg_stream
 from music_assistant.helpers.util import (
     divide_chunks,
+    format_ip_for_url,
     get_ip_addresses,
     get_total_system_memory,
     select_free_port,
@@ -185,7 +186,7 @@ class StreamsController(CoreController):
         values: dict[str, ConfigValueType] | None = None,
     ) -> tuple[ConfigEntry, ...]:
         """Return all Config Entries for this core module (if any)."""
-        ip_addresses = await get_ip_addresses()
+        ip_addresses = await get_ip_addresses(include_ipv6=True)
         default_port = await select_free_port(8097, 9200)
         return (
             ConfigEntry(
@@ -334,7 +335,7 @@ class StreamsController(CoreController):
         await self._server.setup(
             bind_ip=bind_ip,
             bind_port=cast("int", self.publish_port),
-            base_url=f"http://{self.publish_ip}:{self.publish_port}",
+            base_url=f"http://{format_ip_for_url(str(self.publish_ip))}:{self.publish_port}",
             static_routes=[
                 (
                     "*",
index 27603e04c4cc788e726043f60e12a298f3d648f1..fac4f762cb6bcbb96e683d83e4b16859d5a8de94 100644 (file)
@@ -44,7 +44,7 @@ from music_assistant.helpers.api import parse_arguments
 from music_assistant.helpers.audio import get_preview_stream
 from music_assistant.helpers.json import json_dumps, json_loads
 from music_assistant.helpers.redirect_validation import is_allowed_redirect_url
-from music_assistant.helpers.util import get_ip_addresses
+from music_assistant.helpers.util import format_ip_for_url, get_ip_addresses
 from music_assistant.helpers.webserver import Webserver
 from music_assistant.models.core_controller import CoreController
 
@@ -108,7 +108,7 @@ class WebserverController(CoreController):
         values: dict[str, ConfigValueType] | None = None,
     ) -> tuple[ConfigEntry, ...]:
         """Return all Config Entries for this core module (if any)."""
-        ip_addresses = await get_ip_addresses()
+        ip_addresses = await get_ip_addresses(include_ipv6=True)
         default_publish_ip = ip_addresses[0]
 
         # Handle verify SSL action
@@ -123,7 +123,9 @@ class WebserverController(CoreController):
         # Determine if SSL is enabled from values
         ssl_enabled = values.get(CONF_ENABLE_SSL, False) if values else False
         protocol = "https" if ssl_enabled else "http"
-        default_base_url = f"{protocol}://{default_publish_ip}:{DEFAULT_SERVER_PORT}"
+        default_base_url = (
+            f"{protocol}://{format_ip_for_url(default_publish_ip)}:{DEFAULT_SERVER_PORT}"
+        )
         return (
             ConfigEntry(
                 key=CONF_AUTH_ALLOW_SELF_REGISTRATION,
@@ -303,7 +305,7 @@ class WebserverController(CoreController):
         routes.append(("GET", "/sendspin", self._sendspin_proxy.handle_sendspin_proxy))
         await self.auth.setup()
         # start the webserver
-        all_ip_addresses = await get_ip_addresses()
+        all_ip_addresses = await get_ip_addresses(include_ipv6=True)
         default_publish_ip = all_ip_addresses[0]
         if self.mass.running_as_hass_addon:
             # if we're running on the HA supervisor we start an additional TCP site
index 3e228ed2063131638cf972d3455b492331b0de97..5d57e0e095584dfc82a7b9d5f01a5385834fe686 100644 (file)
@@ -338,15 +338,19 @@ async def is_port_in_use(port: int) -> bool:
     """Check if port is in use."""
 
     def _is_port_in_use() -> bool:
-        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as _sock:
-            # Set SO_REUSEADDR to match asyncio.start_server behavior
-            # This allows binding to ports in TIME_WAIT state
-            _sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+        # Try both IPv4 and IPv6 to support single-stack and dual-stack systems.
+        # A port is considered free if it can be bound on at least one address family.
+        for family, addr in ((socket.AF_INET, "0.0.0.0"), (socket.AF_INET6, "::")):
             try:
-                _sock.bind(("0.0.0.0", port))
+                with socket.socket(family, socket.SOCK_STREAM) as _sock:
+                    # Set SO_REUSEADDR to match asyncio.start_server behavior
+                    # This allows binding to ports in TIME_WAIT state
+                    _sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+                    _sock.bind((addr, port))
+                    return False
             except OSError:
-                return True
-        return False
+                continue
+        return True
 
     return await asyncio.to_thread(_is_port_in_use)
 
@@ -381,6 +385,13 @@ async def get_ip_pton(ip_string: str) -> bytes:
         return await asyncio.to_thread(socket.inet_pton, socket.AF_INET6, ip_string)
 
 
+def format_ip_for_url(ip_address: str) -> str:
+    """Wrap IPv6 addresses in brackets for use in URLs (RFC 2732)."""
+    if ":" in ip_address:
+        return f"[{ip_address}]"
+    return ip_address
+
+
 async def get_folder_size(folderpath: str) -> float:
     """Return folder size in gb."""
 
@@ -625,6 +636,12 @@ def get_primary_ip_address_from_zeroconf(discovery_info: AsyncServiceInfo) -> st
             # filter out APIPA address
             continue
         return address
+    # fall back to IPv6 addresses if no usable IPv4 address found
+    for address in discovery_info.parsed_addresses(IPVersion.V6Only):
+        if address.startswith(("::1", "fe80")):
+            # filter out loopback and link-local addresses
+            continue
+        return address
     return None
 
 
index 00d90486af6623aa031663978d937f175c8b4e15..7434c8dd643795a436584e4935effe2d418f2b22 100644 (file)
@@ -150,12 +150,11 @@ class MusicAssistant:
         self.config = ConfigController(self)
         await self.config.setup()
         # create shared zeroconf instance
-        # TODO: enumerate interfaces and enable IPv6 support
         zeroconf_interfaces = self.config.get_raw_core_config_value(
             "streams", CONF_ZEROCONF_INTERFACES, "default"
         )
         self.aiozc = AsyncZeroconf(
-            ip_version=IPVersion.V4Only,
+            ip_version=IPVersion.All,
             interfaces=InterfaceChoice.All
             if zeroconf_interfaces == "all"
             else InterfaceChoice.Default,
index fd9f7de8d95fe0fbd9856744e8d2870ff22ffbcc..e01d4291e32ab4b8a94f7237e9fecbf9045fbfc1 100644 (file)
@@ -53,9 +53,7 @@ class AirPlayProvider(PlayerProvider):
         # for AirPlay 2 (HAP) pair-verify to work with previously paired devices
         self.dacp_id = dacp_id = self.mass.server_id[:16].upper()
         self.logger.debug("Starting DACP ActiveRemote %s on port %s", dacp_id, dacp_port)
-        self._dacp_server = 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, port=dacp_port)
         server_id = f"iTunes_Ctrl_{dacp_id}.{DACP_DISCOVERY_TYPE}"
         self._dacp_info = AsyncServiceInfo(
             DACP_DISCOVERY_TYPE,