From: Marcel van der Veldt Date: Sat, 9 Aug 2025 10:19:43 +0000 (+0200) Subject: Add helpers to setup aiohttp session (#2308) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=01040eb5f1c90adb9043ce5ab6e927c2173ab9c0;p=music-assistant-server.git Add helpers to setup aiohttp session (#2308) --- diff --git a/music_assistant/constants.py b/music_assistant/constants.py index 28bfef21..9e6759bf 100644 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -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 index 00000000..fa6f20e3 --- /dev/null +++ b/music_assistant/helpers/aiohttp_client.py @@ -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 diff --git a/music_assistant/helpers/audio.py b/music_assistant/helpers/audio.py index bea29133..1f3253c1 100644 --- a/music_assistant/helpers/audio.py +++ b/music_assistant/helpers/audio.py @@ -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 diff --git a/music_assistant/helpers/images.py b/music_assistant/helpers/images.py index 2fd31b9e..da323fa8 100644 --- a/music_assistant/helpers/images.py +++ b/music_assistant/helpers/images.py @@ -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 index 00000000..52616570 --- /dev/null +++ b/music_assistant/helpers/ssl.py @@ -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 diff --git a/music_assistant/mass.py b/music_assistant/mass.py index c477990d..53a36ab2 100644 --- a/music_assistant/mass.py +++ b/music_assistant/mass.py @@ -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.""" diff --git a/music_assistant/providers/fanarttv/__init__.py b/music_assistant/providers/fanarttv/__init__.py index 92e13e63..adceb5d2 100644 --- a/music_assistant/providers/fanarttv/__init__.py +++ b/music_assistant/providers/fanarttv/__init__.py @@ -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() diff --git a/music_assistant/providers/fully_kiosk/provider.py b/music_assistant/providers/fully_kiosk/provider.py index fd998d8c..881823f8 100644 --- a/music_assistant/providers/fully_kiosk/provider.py +++ b/music_assistant/providers/fully_kiosk/provider.py @@ -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), diff --git a/music_assistant/providers/hass/__init__.py b/music_assistant/providers/hass/__init__.py index 51e8b23b..125c950a 100644 --- a/music_assistant/providers/hass/__init__.py +++ b/music_assistant/providers/hass/__init__.py @@ -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 diff --git a/music_assistant/providers/jellyfin/__init__.py b/music_assistant/providers/jellyfin/__init__.py index 9e698dcf..a9b3a354 100644 --- a/music_assistant/providers/jellyfin/__init__.py +++ b/music_assistant/providers/jellyfin/__init__.py @@ -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, diff --git a/music_assistant/providers/sonos/player.py b/music_assistant/providers/sonos/player.py index a5d36311..c4069a1e 100644 --- a/music_assistant/providers/sonos/player.py +++ b/music_assistant/providers/sonos/player.py @@ -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, diff --git a/music_assistant/providers/sonos/provider.py b/music_assistant/providers/sonos/provider.py index 08348141..70a6833b 100644 --- a/music_assistant/providers/sonos/provider.py +++ b/music_assistant/providers/sonos/provider.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 88b0c2c8..f177edd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/requirements_all.txt b/requirements_all.txt index 813db8e5..79cdaa0c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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