From 91b11af618d557e1b7102c770e53ae9c36c0472f Mon Sep 17 00:00:00 2001 From: Firdavs Murodov Date: Fri, 6 Feb 2026 10:34:22 +0100 Subject: [PATCH] Enable IPv6 support for zeroconf, stream server and AirPlay DACP (#3086) --- .../controllers/streams/streams_controller.py | 5 +-- .../controllers/webserver/controller.py | 10 +++--- music_assistant/helpers/util.py | 31 ++++++++++++++----- music_assistant/mass.py | 3 +- music_assistant/providers/airplay/provider.py | 4 +-- 5 files changed, 35 insertions(+), 18 deletions(-) diff --git a/music_assistant/controllers/streams/streams_controller.py b/music_assistant/controllers/streams/streams_controller.py index eae753e3..4cf05734 100644 --- a/music_assistant/controllers/streams/streams_controller.py +++ b/music_assistant/controllers/streams/streams_controller.py @@ -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=[ ( "*", diff --git a/music_assistant/controllers/webserver/controller.py b/music_assistant/controllers/webserver/controller.py index 27603e04..fac4f762 100644 --- a/music_assistant/controllers/webserver/controller.py +++ b/music_assistant/controllers/webserver/controller.py @@ -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 diff --git a/music_assistant/helpers/util.py b/music_assistant/helpers/util.py index 3e228ed2..5d57e0e0 100644 --- a/music_assistant/helpers/util.py +++ b/music_assistant/helpers/util.py @@ -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 diff --git a/music_assistant/mass.py b/music_assistant/mass.py index 00d90486..7434c8dd 100644 --- a/music_assistant/mass.py +++ b/music_assistant/mass.py @@ -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, diff --git a/music_assistant/providers/airplay/provider.py b/music_assistant/providers/airplay/provider.py index fd9f7de8..e01d4291 100644 --- a/music_assistant/providers/airplay/provider.py +++ b/music_assistant/providers/airplay/provider.py @@ -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, -- 2.34.1