Add helpers to setup aiohttp session (#2308)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sat, 9 Aug 2025 10:19:43 +0000 (12:19 +0200)
committerGitHub <noreply@github.com>
Sat, 9 Aug 2025 10:19:43 +0000 (12:19 +0200)
14 files changed:
music_assistant/constants.py
music_assistant/helpers/aiohttp_client.py [new file with mode: 0644]
music_assistant/helpers/audio.py
music_assistant/helpers/images.py
music_assistant/helpers/ssl.py [new file with mode: 0644]
music_assistant/mass.py
music_assistant/providers/fanarttv/__init__.py
music_assistant/providers/fully_kiosk/provider.py
music_assistant/providers/hass/__init__.py
music_assistant/providers/jellyfin/__init__.py
music_assistant/providers/sonos/player.py
music_assistant/providers/sonos/provider.py
pyproject.toml
requirements_all.txt

index 28bfef21c821d25a1d97a55d369f3a48842fd066..9e6759bf338cf43450c80bc70116b28b4f386c07 100644 (file)
@@ -11,6 +11,9 @@ from music_assistant_models.config_entries import (
 from music_assistant_models.enums import ConfigEntryType, ContentType, HidePlayerOption
 from music_assistant_models.media_items import AudioFormat
 
+APPLICATION_NAME: Final = "Music Assistant"
+
+
 API_SCHEMA_VERSION: Final[int] = 27
 MIN_SCHEMA_VERSION: Final[int] = 24
 
@@ -675,15 +678,15 @@ def create_sample_rates_config_entry(
 
 
 DEFAULT_STREAM_HEADERS = {
-    "Server": "Music Assistant",
+    "Server": APPLICATION_NAME,
     "transferMode.dlna.org": "Streaming",
     "contentFeatures.dlna.org": "DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000",  # noqa: E501
     "Cache-Control": "no-cache",
     "Pragma": "no-cache",
 }
 ICY_HEADERS = {
-    "icy-name": "Music Assistant",
-    "icy-description": "Music Assistant - Your personal music assistant",
+    "icy-name": APPLICATION_NAME,
+    "icy-description": f"{APPLICATION_NAME} - Your personal music assistant",
     "icy-version": "1",
     "icy-logo": MASS_LOGO_ONLINE,
 }
diff --git a/music_assistant/helpers/aiohttp_client.py b/music_assistant/helpers/aiohttp_client.py
new file mode 100644 (file)
index 0000000..fa6f20e
--- /dev/null
@@ -0,0 +1,195 @@
+"""Helpers for setting up a aiohttp session (and related)."""
+
+from __future__ import annotations
+
+import asyncio
+import socket
+import sys
+from contextlib import suppress
+from functools import cache
+from ssl import SSLContext
+from types import MappingProxyType
+from typing import TYPE_CHECKING, Any, Self
+
+import aiohttp
+from aiohttp import web
+from aiohttp.hdrs import USER_AGENT
+from aiohttp_asyncmdnsresolver.api import AsyncDualMDNSResolver
+from music_assistant_models.enums import EventType
+
+from music_assistant.constants import APPLICATION_NAME
+
+from . import ssl as ssl_util
+from .json import json_dumps, json_loads
+
+if TYPE_CHECKING:
+    from aiohttp.typedefs import JSONDecoder
+    from music_assistant_models.event import MassEvent
+
+    from music_assistant.mass import MusicAssistant
+
+
+MAXIMUM_CONNECTIONS = 4096
+MAXIMUM_CONNECTIONS_PER_HOST = 100
+
+
+def create_clientsession(
+    mass: MusicAssistant,
+    verify_ssl: bool = True,
+    **kwargs: Any,
+) -> aiohttp.ClientSession:
+    """Create a new ClientSession with kwargs, i.e. for cookies."""
+    clientsession = aiohttp.ClientSession(
+        connector=_get_connector(mass, verify_ssl),
+        json_serialize=json_dumps,
+        response_class=MassClientResponse,
+        **kwargs,
+    )
+    # Prevent packages accidentally overriding our default headers
+    # It's important that we identify as Music Assistant
+    # If a package requires a different user agent, override it by passing a headers
+    # dictionary to the request method.
+    user_agent = (
+        f"{APPLICATION_NAME}/{mass.version} "
+        f"aiohttp/{aiohttp.__version__} Python/{sys.version_info[0]}.{sys.version_info[1]}"
+    )
+    clientsession._default_headers = MappingProxyType(  # type: ignore[assignment]
+        {USER_AGENT: user_agent},
+    )
+    return clientsession
+
+
+async def async_aiohttp_proxy_stream(
+    mass: MusicAssistant,
+    request: web.BaseRequest,
+    stream: aiohttp.StreamReader,
+    content_type: str | None,
+    buffer_size: int = 102400,
+    timeout: int = 10,
+) -> web.StreamResponse:
+    """Stream a stream to aiohttp web response."""
+    response = web.StreamResponse()
+    if content_type is not None:
+        response.content_type = content_type
+    await response.prepare(request)
+
+    # Suppressing something went wrong fetching data, closed connection
+    with suppress(TimeoutError, aiohttp.ClientError):
+        while not mass.closing:
+            async with asyncio.timeout(timeout):
+                data = await stream.read(buffer_size)
+
+            if not data:
+                break
+            await response.write(data)
+
+    return response
+
+
+class MassAsyncDNSResolver(AsyncDualMDNSResolver):
+    """Music Assistant AsyncDNSResolver.
+
+    This is a wrapper around the AsyncDualMDNSResolver to only
+    close the resolver when the Music Assistant instance is closed.
+    """
+
+    async def real_close(self) -> None:
+        """Close the resolver."""
+        await super().close()
+
+    async def close(self) -> None:
+        """Close the resolver."""
+
+
+class MassClientResponse(aiohttp.ClientResponse):
+    """aiohttp.ClientResponse with a json method that uses json_loads by default."""
+
+    async def json(
+        self,
+        *args: Any,
+        loads: JSONDecoder = json_loads,
+        **kwargs: Any,
+    ) -> Any:
+        """Send a json request and parse the json response."""
+        return await super().json(*args, loads=loads, **kwargs)
+
+
+class ChunkAsyncStreamIterator:
+    """
+    Async iterator for chunked streams.
+
+    Based on aiohttp.streams.ChunkTupleAsyncStreamIterator, but yields
+    bytes instead of tuple[bytes, bool].
+    """
+
+    __slots__ = ("_stream",)
+
+    def __init__(self, stream: aiohttp.StreamReader) -> None:
+        """Initialize."""
+        self._stream = stream
+
+    def __aiter__(self) -> Self:
+        """Iterate."""
+        return self
+
+    async def __anext__(self) -> bytes:
+        """Yield next chunk."""
+        rv = await self._stream.readchunk()
+        if rv == (b"", False):
+            raise StopAsyncIteration
+        return rv[0]
+
+
+class MusicAssistantTCPConnector(aiohttp.TCPConnector):
+    """Music Assistant TCP Connector.
+
+    Same as aiohttp.TCPConnector but with a longer cleanup_closed timeout.
+
+    By default the cleanup_closed timeout is 2 seconds. This is too short
+    for Music Assistant since we churn through a lot of connections. We set
+    it to 60 seconds to reduce the overhead of aborting TLS connections
+    that are likely already closed.
+    """
+
+    # abort transport after 60 seconds (cleanup broken connections)
+    _cleanup_closed_period = 60.0
+
+
+def _get_connector(
+    mass: MusicAssistant,
+    verify_ssl: bool = True,
+    family: socket.AddressFamily = socket.AF_UNSPEC,
+    ssl_cipher: ssl_util.SSLCipherList = ssl_util.SSLCipherList.PYTHON_DEFAULT,
+) -> aiohttp.BaseConnector:
+    """
+    Return the connector pool for aiohttp.
+
+    This method must be run in the event loop.
+    """
+    if verify_ssl:
+        ssl_context: SSLContext = ssl_util.client_context(ssl_cipher)
+    else:
+        ssl_context = ssl_util.client_context_no_verify(ssl_cipher)
+
+    return MusicAssistantTCPConnector(
+        family=family,
+        # Cleanup closed is no longer needed after https://github.com/python/cpython/pull/118960
+        # which first appeared in Python 3.12.7 and 3.13.1
+        enable_cleanup_closed=False,
+        ssl=ssl_context,
+        limit=MAXIMUM_CONNECTIONS,
+        limit_per_host=MAXIMUM_CONNECTIONS_PER_HOST,
+        resolver=_get_resolver(mass),
+    )
+
+
+@cache
+def _get_resolver(mass: MusicAssistant) -> MassAsyncDNSResolver:
+    """Return the MassAsyncDNSResolver."""
+    resolver = MassAsyncDNSResolver(async_zeroconf=mass.aiozc)
+
+    async def _close_resolver(event: MassEvent) -> None:  # noqa: ARG001
+        await resolver.real_close()
+
+    mass.subscribe(_close_resolver, EventType.SHUTDOWN)
+    return resolver
index bea29133438d49838e212dc8ae7b858bb9e7435e..1f3253c1d2cbae512ba7eded33c969701d5db296 100644 (file)
@@ -1037,7 +1037,7 @@ async def resolve_radio_stream(mass: MusicAssistant, url: str) -> tuple[str, Str
     resolved_url = url
     timeout = ClientTimeout(total=0, connect=10, sock_read=5)
     try:
-        async with mass.http_session.get(
+        async with mass.http_session_no_ssl.get(
             url, headers=HTTP_HEADERS_ICY, allow_redirects=True, timeout=timeout
         ) as resp:
             headers = resp.headers
@@ -1083,7 +1083,7 @@ async def get_icy_radio_stream(
     """Get (radio) audio stream from HTTP, including ICY metadata retrieval."""
     timeout = ClientTimeout(total=0, connect=30, sock_read=5 * 60)
     LOGGER.debug("Start streaming radio with ICY metadata from url %s", url)
-    async with mass.http_session.get(
+    async with mass.http_session_no_ssl.get(
         url, allow_redirects=True, headers=HTTP_HEADERS_ICY, timeout=timeout
     ) as resp:
         headers = resp.headers
@@ -1133,7 +1133,7 @@ async def get_hls_substream(
     timeout = ClientTimeout(total=0, connect=30, sock_read=5 * 60)
     # fetch master playlist and select (best) child playlist
     # https://datatracker.ietf.org/doc/html/draft-pantos-http-live-streaming-19#section-10
-    async with mass.http_session.get(
+    async with mass.http_session_no_ssl.get(
         url, allow_redirects=True, headers=HTTP_HEADERS, timeout=timeout
     ) as resp:
         resp.raise_for_status()
@@ -1173,15 +1173,17 @@ async def get_http_stream(
     url: str,
     streamdetails: StreamDetails,
     seek_position: int = 0,
+    verify_ssl: bool = True,
 ) -> AsyncGenerator[bytes, None]:
     """Get audio stream from HTTP."""
     LOGGER.debug("Start HTTP stream for %s (seek_position %s)", streamdetails.uri, seek_position)
     if seek_position:
         assert streamdetails.duration, "Duration required for seek requests"
+    http_session = mass.http_session if verify_ssl else mass.http_session_no_ssl
     # try to get filesize with a head request
     seek_supported = streamdetails.can_seek
     if seek_position or not streamdetails.size:
-        async with mass.http_session.head(url, allow_redirects=True, headers=HTTP_HEADERS) as resp:
+        async with http_session.head(url, allow_redirects=True, headers=HTTP_HEADERS) as resp:
             resp.raise_for_status()
             if size := resp.headers.get("Content-Length"):
                 streamdetails.size = int(size)
@@ -1215,7 +1217,7 @@ async def get_http_stream(
 
     # start the streaming from http
     bytes_received = 0
-    async with mass.http_session.get(
+    async with http_session.get(
         url, allow_redirects=True, headers=headers, timeout=timeout
     ) as resp:
         is_partial = resp.status == 206
index 2fd31b9e0d49a64659fce8070ef82418db663615..da323fa80c5298bfe3fc36e32ebcb39a426d860b 100644 (file)
@@ -39,7 +39,7 @@ async def get_image_data(mass: MusicAssistant, path_or_url: str, provider: str)
     # handle HTTP location
     if path_or_url.startswith("http"):
         try:
-            async with mass.http_session.get(path_or_url, raise_for_status=True) as resp:
+            async with mass.http_session_no_ssl.get(path_or_url, raise_for_status=True) as resp:
                 return await resp.read()
         except ClientError as err:
             raise FileNotFoundError from err
diff --git a/music_assistant/helpers/ssl.py b/music_assistant/helpers/ssl.py
new file mode 100644 (file)
index 0000000..5261657
--- /dev/null
@@ -0,0 +1,197 @@
+"""Helper to create SSL contexts."""
+
+import contextlib
+import ssl
+from enum import StrEnum
+from functools import cache
+from os import environ
+
+import certifi
+
+
+class SSLCipherList(StrEnum):
+    """SSL cipher lists."""
+
+    PYTHON_DEFAULT = "python_default"
+    INTERMEDIATE = "intermediate"
+    MODERN = "modern"
+    INSECURE = "insecure"
+
+
+SSL_CIPHER_LISTS = {
+    SSLCipherList.INTERMEDIATE: (
+        "ECDHE-ECDSA-CHACHA20-POLY1305:"
+        "ECDHE-RSA-CHACHA20-POLY1305:"
+        "ECDHE-ECDSA-AES128-GCM-SHA256:"
+        "ECDHE-RSA-AES128-GCM-SHA256:"
+        "ECDHE-ECDSA-AES256-GCM-SHA384:"
+        "ECDHE-RSA-AES256-GCM-SHA384:"
+        "DHE-RSA-AES128-GCM-SHA256:"
+        "DHE-RSA-AES256-GCM-SHA384:"
+        "ECDHE-ECDSA-AES128-SHA256:"
+        "ECDHE-RSA-AES128-SHA256:"
+        "ECDHE-ECDSA-AES128-SHA:"
+        "ECDHE-RSA-AES256-SHA384:"
+        "ECDHE-RSA-AES128-SHA:"
+        "ECDHE-ECDSA-AES256-SHA384:"
+        "ECDHE-ECDSA-AES256-SHA:"
+        "ECDHE-RSA-AES256-SHA:"
+        "DHE-RSA-AES128-SHA256:"
+        "DHE-RSA-AES128-SHA:"
+        "DHE-RSA-AES256-SHA256:"
+        "DHE-RSA-AES256-SHA:"
+        "ECDHE-ECDSA-DES-CBC3-SHA:"
+        "ECDHE-RSA-DES-CBC3-SHA:"
+        "EDH-RSA-DES-CBC3-SHA:"
+        "AES128-GCM-SHA256:"
+        "AES256-GCM-SHA384:"
+        "AES128-SHA256:"
+        "AES256-SHA256:"
+        "AES128-SHA:"
+        "AES256-SHA:"
+        "DES-CBC3-SHA:"
+        "!DSS"
+    ),
+    SSLCipherList.MODERN: (
+        "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:"
+        "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:"
+        "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:"
+        "ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:"
+        "ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256"
+    ),
+    SSLCipherList.INSECURE: "DEFAULT:@SECLEVEL=0",
+}
+
+
+@cache
+def _client_context_no_verify(ssl_cipher_list: SSLCipherList) -> ssl.SSLContext:
+    # This is a copy of aiohttp's create_default_context() function, with the
+    # ssl verify turned off.
+    # https://github.com/aio-libs/aiohttp/blob/33953f110e97eecc707e1402daa8d543f38a189b/aiohttp/connector.py#L911
+
+    sslcontext = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+    sslcontext.check_hostname = False
+    sslcontext.verify_mode = ssl.CERT_NONE
+    with contextlib.suppress(AttributeError):
+        # This only works for OpenSSL >= 1.0.0
+        sslcontext.options |= ssl.OP_NO_COMPRESSION
+    sslcontext.set_default_verify_paths()
+    if ssl_cipher_list != SSLCipherList.PYTHON_DEFAULT:
+        sslcontext.set_ciphers(SSL_CIPHER_LISTS[ssl_cipher_list])
+
+    return sslcontext
+
+
+def _create_client_context(
+    ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT,
+) -> ssl.SSLContext:
+    """Return an independent SSL context for making requests."""
+    # Reuse environment variable definition from requests, since it's already a
+    # requirement. If the environment variable has no value, fall back to using
+    # certs from certifi package.
+    cafile = environ.get("REQUESTS_CA_BUNDLE", certifi.where())
+
+    sslcontext = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH, cafile=cafile)
+    if ssl_cipher_list != SSLCipherList.PYTHON_DEFAULT:
+        sslcontext.set_ciphers(SSL_CIPHER_LISTS[ssl_cipher_list])
+
+    return sslcontext
+
+
+@cache
+def _client_context(
+    ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT,
+) -> ssl.SSLContext:
+    # Cached version of _create_client_context
+    return _create_client_context(ssl_cipher_list)
+
+
+# Create this only once and reuse it
+_DEFAULT_SSL_CONTEXT = _client_context(SSLCipherList.PYTHON_DEFAULT)
+_DEFAULT_NO_VERIFY_SSL_CONTEXT = _client_context_no_verify(SSLCipherList.PYTHON_DEFAULT)
+_NO_VERIFY_SSL_CONTEXTS = {
+    SSLCipherList.INTERMEDIATE: _client_context_no_verify(SSLCipherList.INTERMEDIATE),
+    SSLCipherList.MODERN: _client_context_no_verify(SSLCipherList.MODERN),
+    SSLCipherList.INSECURE: _client_context_no_verify(SSLCipherList.INSECURE),
+}
+_SSL_CONTEXTS = {
+    SSLCipherList.INTERMEDIATE: _client_context(SSLCipherList.INTERMEDIATE),
+    SSLCipherList.MODERN: _client_context(SSLCipherList.MODERN),
+    SSLCipherList.INSECURE: _client_context(SSLCipherList.INSECURE),
+}
+
+
+def get_default_context() -> ssl.SSLContext:
+    """Return the default SSL context."""
+    return _DEFAULT_SSL_CONTEXT
+
+
+def get_default_no_verify_context() -> ssl.SSLContext:
+    """Return the default SSL context that does not verify the server certificate."""
+    return _DEFAULT_NO_VERIFY_SSL_CONTEXT
+
+
+def client_context_no_verify(
+    ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT,
+) -> ssl.SSLContext:
+    """Return a SSL context with no verification with a specific ssl cipher."""
+    return _NO_VERIFY_SSL_CONTEXTS.get(ssl_cipher_list, _DEFAULT_NO_VERIFY_SSL_CONTEXT)
+
+
+def client_context(
+    ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT,
+) -> ssl.SSLContext:
+    """Return an SSL context for making requests."""
+    return _SSL_CONTEXTS.get(ssl_cipher_list, _DEFAULT_SSL_CONTEXT)
+
+
+def create_client_context(
+    ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT,
+) -> ssl.SSLContext:
+    """Return an independent SSL context for making requests."""
+    # This explicitly uses the non-cached version to create a client context
+    return _create_client_context(ssl_cipher_list)
+
+
+def create_no_verify_ssl_context(
+    ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT,
+) -> ssl.SSLContext:
+    """Return an SSL context that does not verify the server certificate."""
+    return _client_context_no_verify(ssl_cipher_list)
+
+
+def server_context_modern() -> ssl.SSLContext:
+    """Return an SSL context following the Mozilla recommendations.
+
+    TLS configuration follows the best-practice guidelines specified here:
+    https://wiki.mozilla.org/Security/Server_Side_TLS
+    Modern guidelines are followed.
+    """
+    context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+    context.minimum_version = ssl.TLSVersion.TLSv1_2
+
+    context.options |= ssl.OP_CIPHER_SERVER_PREFERENCE
+    if hasattr(ssl, "OP_NO_COMPRESSION"):
+        context.options |= ssl.OP_NO_COMPRESSION
+
+    context.set_ciphers(SSL_CIPHER_LISTS[SSLCipherList.MODERN])
+
+    return context
+
+
+def server_context_intermediate() -> ssl.SSLContext:
+    """Return an SSL context following the Mozilla recommendations.
+
+    TLS configuration follows the best-practice guidelines specified here:
+    https://wiki.mozilla.org/Security/Server_Side_TLS
+    Intermediate guidelines are followed.
+    """
+    context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+
+    context.options |= ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_CIPHER_SERVER_PREFERENCE
+    if hasattr(ssl, "OP_NO_COMPRESSION"):
+        context.options |= ssl.OP_NO_COMPRESSION
+
+    context.set_ciphers(SSL_CIPHER_LISTS[SSLCipherList.INTERMEDIATE])
+
+    return context
index c477990d0efb034c20cc13137cad6eb58caea5e0..53a36ab2799b2265a715a55984e4f1c38cb8ad5b 100644 (file)
@@ -13,7 +13,6 @@ from uuid import uuid4
 
 import aiofiles
 from aiofiles.os import wrap
-from aiohttp import ClientSession, TCPConnector
 from music_assistant_models.api import ServerInfoMessage
 from music_assistant_models.enums import EventType, ProviderType
 from music_assistant_models.errors import MusicAssistantError, SetupFailedError
@@ -46,6 +45,7 @@ from music_assistant.controllers.player_queues import PlayerQueuesController
 from music_assistant.controllers.players import PlayerController
 from music_assistant.controllers.streams import StreamsController
 from music_assistant.controllers.webserver import WebserverController
+from music_assistant.helpers.aiohttp_client import create_clientsession
 from music_assistant.helpers.api import APICommandHandler, api_command
 from music_assistant.helpers.images import get_icon_string
 from music_assistant.helpers.util import (
@@ -62,6 +62,7 @@ from music_assistant.models.player_provider import PlayerProvider
 if TYPE_CHECKING:
     from types import TracebackType
 
+    from aiohttp import ClientSession
     from music_assistant_models.config_entries import ProviderConfig
 
     from music_assistant.models.core_controller import CoreController
@@ -100,7 +101,6 @@ class MusicAssistant:
     """Main MusicAssistant (Server) object."""
 
     loop: asyncio.AbstractEventLoop
-    http_session: ClientSession
     aiozc: AsyncZeroconf
     config: ConfigController
     webserver: WebserverController
@@ -131,6 +131,8 @@ class MusicAssistant:
             os.environ.get("PYTHONDEVMODE") == "1"
             or pathlib.Path(__file__).parent.resolve().parent.resolve().joinpath(".venv").exists()
         )
+        self._http_session: ClientSession | None = None
+        self._http_session_no_ssl: ClientSession | None = None
 
     async def start(self) -> None:
         """Start running the Music Assistant server."""
@@ -141,15 +143,6 @@ class MusicAssistant:
         # create shared zeroconf instance
         # TODO: enumerate interfaces and enable IPv6 support
         self.aiozc = AsyncZeroconf(ip_version=IPVersion.V4Only, interfaces=InterfaceChoice.Default)
-        # create shared aiohttp ClientSession
-        self.http_session = ClientSession(
-            loop=self.loop,
-            connector=TCPConnector(
-                ssl=True,
-                limit=4096,
-                limit_per_host=100,
-            ),
-        )
         # load all available providers from manifest files
         await self.__load_provider_manifests()
         # setup config controller first and fetch important config values
@@ -217,8 +210,14 @@ class MusicAssistant:
         await self.config.close()
         await self.cache.close()
         # close/cleanup shared http session
-        if self.http_session:
-            await self.http_session.close()
+        if self._http_session:
+            self._http_session.detach()
+            if self._http_session.connector:
+                await self._http_session.connector.close()
+        if self._http_session_no_ssl:
+            self._http_session_no_ssl.detach()
+            if self._http_session_no_ssl.connector:
+                await self._http_session_no_ssl.connector.close()
 
     @property
     def server_id(self) -> str:
@@ -227,6 +226,28 @@ class MusicAssistant:
             return ""
         return self.config.get(CONF_SERVER_ID)  # type: ignore[no-any-return]
 
+    @property
+    def http_session(self) -> ClientSession:
+        """
+        Return the shared HTTP Client session (with SSL).
+
+        NOTE: May only be called from the event loop.
+        """
+        if self._http_session is None:
+            self._http_session = create_clientsession(self, verify_ssl=True)
+        return self._http_session
+
+    @property
+    def http_session_no_ssl(self) -> ClientSession:
+        """
+        Return the shared HTTP Client session (without SSL).
+
+        NOTE: May only be called from the event loop thread.
+        """
+        if self._http_session_no_ssl is None:
+            self._http_session_no_ssl = create_clientsession(self, verify_ssl=False)
+        return self._http_session_no_ssl
+
     @api_command("info")
     def get_server_info(self) -> ServerInfoMessage:
         """Return Info of this server."""
index 92e13e63d13137023abddfdc6b0a3e9c4b36c6be..adceb5d2f9f6c4314af5372e9c37d06bd2ddb4c3 100644 (file)
@@ -169,7 +169,9 @@ class FanartTvMetadataProvider(MetadataProvider):
             headers["client_key"] = client_key
         async with (
             self.throttler,
-            self.mass.http_session.get(url, params=kwargs, headers=headers, ssl=False) as response,
+            self.mass.http_session_no_ssl.get(
+                url, params=kwargs, headers=headers, ssl=False
+            ) as response,
         ):
             try:
                 result = await response.json()
index fd998d8cfef835f6b5ec451ebc6477af06244aa9..881823f8c427cd38de3e8a11dafb426bddced47d 100644 (file)
@@ -25,7 +25,7 @@ class FullyKioskProvider(PlayerProvider):
         else:
             logging.getLogger("fullykiosk").setLevel(self.logger.level + 10)
         fully_kiosk = FullyKiosk(
-            self.mass.http_session,
+            self.mass.http_session_no_ssl,
             self.config.get_value(CONF_IP_ADDRESS),
             self.config.get_value(CONF_PORT),
             self.config.get_value(CONF_PASSWORD),
index 51e8b23b4ed58074a7ecd9218a079b1d80b6c85c..125c950a7a3adf7e1d847ced362b5420859ac605 100644 (file)
@@ -304,9 +304,11 @@ class HomeAssistantProvider(PluginProvider):
         url = get_websocket_url(self.config.get_value(CONF_URL))
         token = self.config.get_value(CONF_AUTH_TOKEN)
         logging.getLogger("hass_client").setLevel(self.logger.level + 10)
-        self.hass = HomeAssistantClient(url, token, self.mass.http_session)
+        ssl = bool(self.config.get_value(CONF_VERIFY_SSL))
+        http_session = self.mass.http_session if ssl else self.mass.http_session_no_ssl
+        self.hass = HomeAssistantClient(url, token, http_session)
         try:
-            await self.hass.connect(ssl=bool(self.config.get_value(CONF_VERIFY_SSL)))
+            await self.hass.connect()
         except BaseHassClientError as err:
             err_msg = str(err) or err.__class__.__name__
             raise SetupFailedError(err_msg) from err
index 9e698dcf8702c713398a524da5f5f97c96d3f75b..a9b3a3547b59d9117751cfb777962f42e9daee97 100644 (file)
@@ -139,9 +139,11 @@ class JellyfinProvider(MusicProvider):
         # to be an opaque identifier
 
         device_id = hashlib.sha256(f"{self.mass.server_id}+{username}".encode()).hexdigest()
+        verify_ssl = bool(self.config.get_value(CONF_VERIFY_SSL))
+        http_session = self.mass.http_session if verify_ssl else self.mass.http_session_no_ssl
 
         session_config = SessionConfiguration(
-            session=self.mass.http_session,
+            session=http_session,
             url=str(self.config.get_value(CONF_URL)),
             verify_ssl=bool(self.config.get_value(CONF_VERIFY_SSL)),
             app_name=USER_APP_NAME,
index a5d3631148a0f8bfa77838f528107b029d9ba3e5..c4069a1efc9d89a6297cb41c27bb9e7b0ca63a00 100644 (file)
@@ -123,7 +123,9 @@ class SonosPlayer(Player):
     async def setup(self) -> None:
         """Handle setup of the player."""
         # connect the player first so we can fail early
-        self.client = SonosLocalApiClient(self.device_info.ip_address, self.mass.http_session)
+        self.client = SonosLocalApiClient(
+            self.device_info.ip_address, self.mass.http_session_no_ssl
+        )
         await self._connect(False)
 
         # collect supported features
@@ -855,7 +857,7 @@ class SonosPlayer(Player):
         """Handle PLAY MEDIA using the legacy upnp api."""
         xml_data, soap_action = get_xml_soap_set_url(media)
         player_ip = self.device_info.ip_address
-        async with self.mass.http_session.post(
+        async with self.mass.http_session_no_ssl.post(
             f"http://{player_ip}:1400/MediaRenderer/AVTransport/Control",
             headers={
                 "SOAPACTION": soap_action,
index 083481419a64739828c9a2dcd098f2bf27f5c8b7..70a6833bd9d4cf368fb90c99edbab92bb7888eda 100644 (file)
@@ -63,7 +63,7 @@ class SonosPlayerProvider(PlayerProvider):
         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)
+                discovery_info = await get_discovery_info(self.mass.http_session_no_ssl, ip_address)
             except ClientError as err:
                 self.logger.debug(
                     "Ignoring %s (manual IP) as it is not reachable: %s", ip_address, str(err)
@@ -142,7 +142,7 @@ class SonosPlayerProvider(PlayerProvider):
             self.logger.debug("Ignoring %s in discovery as it is disabled.", name)
             return
         try:
-            discovery_info = await get_discovery_info(self.mass.http_session, address)
+            discovery_info = await get_discovery_info(self.mass.http_session_no_ssl, address)
         except ClientError as err:
             self.logger.debug("Ignoring %s in discovery as it is not reachable: %s", name, str(err))
             return
index 88b0c2c8ed75ddfb30a1e0d7d7d424af49503010..f177edd59fc6734d4f97d322a3b454349316327d 100644 (file)
@@ -11,6 +11,7 @@ classifiers = [
 ]
 dependencies = [
   "aiodns>=3.2.0",
+  "aiohttp_asyncmdnsresolver==0.1.1",
   "Brotli>=1.0.9",
   "aiohttp==3.12.15",
   "aiohttp-fast-zlib==0.3.0",
index 813db8e51ab9d66ff963d72f89052fd48f84760e..79cdaa0cbd144855c447b081e7bf12ff901e6fd5 100644 (file)
@@ -5,6 +5,7 @@ aioaudiobookshelf==0.1.7
 aiodns>=3.2.0
 aiofiles==24.1.0
 aiohttp==3.12.15
+aiohttp_asyncmdnsresolver==0.1.1
 aiohttp-fast-zlib==0.3.0
 aiojellyfin==0.14.1
 aiomusiccast==0.14.8