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
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,
}
--- /dev/null
+"""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
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
"""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
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()
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)
# 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
# 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
--- /dev/null
+"""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
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
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 (
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
"""Main MusicAssistant (Server) object."""
loop: asyncio.AbstractEventLoop
- http_session: ClientSession
aiozc: AsyncZeroconf
config: ConfigController
webserver: WebserverController
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."""
# 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
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:
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."""
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()
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),
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
# 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,
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
"""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,
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)
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
]
dependencies = [
"aiodns>=3.2.0",
+ "aiohttp_asyncmdnsresolver==0.1.1",
"Brotli>=1.0.9",
"aiohttp==3.12.15",
"aiohttp-fast-zlib==0.3.0",
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