From: Brian O'Connor Date: Sun, 20 Oct 2024 19:48:16 +0000 (-0400) Subject: Add SiriusXM Music Provider (#1730) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=818e168f4234dfbb2ca1a4f3374273e4a16f8666;p=music-assistant-server.git Add SiriusXM Music Provider (#1730) --- diff --git a/music_assistant/server/providers/siriusxm/__init__.py b/music_assistant/server/providers/siriusxm/__init__.py new file mode 100644 index 00000000..6e94331c --- /dev/null +++ b/music_assistant/server/providers/siriusxm/__init__.py @@ -0,0 +1,301 @@ +"""SiriusXM Music Provider for Music Assistant.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, Awaitable, Sequence +from typing import TYPE_CHECKING, Any, cast + +from music_assistant.common.helpers.util import select_free_port +from music_assistant.common.models.config_entries import ( + ConfigEntry, + ConfigValueOption, + ConfigValueType, +) +from music_assistant.common.models.enums import ( + ConfigEntryType, + ContentType, + LinkType, + MediaType, + ProviderFeature, + StreamType, +) +from music_assistant.common.models.errors import LoginFailed, MediaNotFoundError +from music_assistant.common.models.media_items import ( + AudioFormat, + ImageType, + ItemMapping, + MediaItemImage, + MediaItemLink, + MediaItemType, + ProviderMapping, + Radio, +) +from music_assistant.common.models.streamdetails import StreamDetails +from music_assistant.server.helpers.webserver import Webserver +from music_assistant.server.models.music_provider import MusicProvider + +if TYPE_CHECKING: + from music_assistant.common.models.config_entries import ProviderConfig + from music_assistant.common.models.provider import ProviderManifest + from music_assistant.server import MusicAssistant + from music_assistant.server.models import ProviderInstanceType + +import sxm.http +from sxm import SXMClientAsync +from sxm.models import QualitySize, RegionChoice, XMChannel + +CONF_SXM_USERNAME = "sxm_email_address" +CONF_SXM_PASSWORD = "sxm_password" +CONF_SXM_REGION = "sxm_region" + + +async def setup( + mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig +) -> ProviderInstanceType: + """Initialize provider(instance) with given configuration.""" + return SiriusXMProvider(mass, manifest, config) + + +async def get_config_entries( + mass: MusicAssistant, + instance_id: str | None = None, + action: str | None = None, + values: dict[str, ConfigValueType] | None = None, +) -> tuple[ConfigEntry, ...]: + """ + Return Config entries to setup this provider. + + instance_id: id of an existing provider instance (None if new instance setup). + action: [optional] action key called from config entries UI. + values: the (intermediate) raw values for config entries sent with the action. + """ + # ruff: noqa: ARG001 + return ( + ConfigEntry( + key=CONF_SXM_USERNAME, + type=ConfigEntryType.STRING, + label="Username", + required=True, + ), + ConfigEntry( + key=CONF_SXM_PASSWORD, + type=ConfigEntryType.SECURE_STRING, + label="Password", + required=True, + ), + ConfigEntry( + key=CONF_SXM_REGION, + type=ConfigEntryType.STRING, + default_value="US", + options=( + ConfigValueOption(title="United States", value="US"), + ConfigValueOption(title="Canada", value="CA"), + ), + label="Region", + required=True, + ), + ) + + +class SiriusXMProvider(MusicProvider): + """SiriusXM Music Provider.""" + + _username: str + _password: str + _region: str + _client: SXMClientAsync + + _channels: list[XMChannel] + + _sxm_server: Webserver + _base_url: str + + @property + def supported_features(self) -> tuple[ProviderFeature, ...]: + """Return the features supported by this Provider.""" + return ( + ProviderFeature.BROWSE, + ProviderFeature.LIBRARY_RADIOS, + ) + + async def handle_async_init(self) -> None: + """Handle async initialization of the provider.""" + username: str = self.config.get_value(CONF_SXM_USERNAME) + password: str = self.config.get_value(CONF_SXM_PASSWORD) + + region: RegionChoice = ( + RegionChoice.US if self.config.get_value(CONF_SXM_REGION) == "US" else RegionChoice.CA + ) + + self._client = SXMClientAsync( + username, + password, + region, + quality=QualitySize.LARGE_256k, + update_handler=self._channel_updated, + ) + + self.logger.info("Authenticating with SiriusXM") + if not await self._client.authenticate(): + raise LoginFailed("Could not login to SiriusXM") + + self.logger.info("Successfully authenticated") + + await self._refresh_channels() + + # Set up the sxm server for streaming + bind_ip = "127.0.0.1" + bind_port = await select_free_port(8100, 9999) + + self._base_url = f"{bind_ip}:{bind_port}" + http_handler = sxm.http.make_http_handler(self._client) + + self._sxm_server = Webserver(self.logger) + + await self._sxm_server.setup( + bind_ip=bind_ip, + bind_port=bind_port, + base_url=self._base_url, + static_routes=[ + ("*", "/{tail:.*}", cast(Awaitable, http_handler)), + ], + ) + + self.logger.debug(f"SXM Proxy server running at {bind_ip}:{bind_port}") + + async def unload(self) -> None: + """ + Handle unload/close of the provider. + + Called when provider is deregistered (e.g. MA exiting or config reloading). + """ + await self._sxm_server.close() + + @property + def is_streaming_provider(self) -> bool: + """ + Return True if the provider is a streaming provider. + + This literally means that the catalog is not the same as the library contents. + For local based providers (files, plex), the catalog is the same as the library content. + It also means that data is if this provider is NOT a streaming provider, + data cross instances is unique, the catalog and library differs per instance. + + Setting this to True will only query one instance of the provider for search and lookups. + Setting this to False will query all instances of this provider for search and lookups. + """ + return True + + async def get_library_radios(self) -> AsyncGenerator[Radio, None]: + """Retrieve library/subscribed radio stations from the provider.""" + for channel in self._channels_by_id.values(): + if channel.is_favorite: + yield self._parse_radio(channel) + + async def get_radio(self, prov_radio_id: str) -> Radio: # type: ignore[return] + """Get full radio details by id.""" + if prov_radio_id not in self._channels_by_id: + raise MediaNotFoundError("Station not found") + + return self._parse_radio(self._channels_by_id[prov_radio_id]) + + async def get_stream_details(self, item_id: str) -> StreamDetails: + """Get streamdetails for a track/radio.""" + hls_path = f"http://{self._base_url}/{item_id}.m3u8" + + return StreamDetails( + item_id=item_id, + provider=self.instance_id, + audio_format=AudioFormat( + content_type=ContentType.AAC, + ), + stream_type=StreamType.ENCRYPTED_HLS, + media_type=MediaType.RADIO, + path=hls_path, + can_seek=False, + ) + + async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping]: + """Browse this provider's items. + + :param path: The path to browse, (e.g. provider_id://artists). + """ + return [self._parse_radio(channel) for channel in self._channels] + + def _channel_updated(self, live_channel_raw: dict[str, Any]) -> None: + self.logger.debug(f"channel updated {live_channel_raw}") + + async def _refresh_channels(self) -> bool: + self._channels = await self._client.channels + + self._channels_by_id = {} + + for channel in self._channels: + self._channels_by_id[channel.id] = channel + + return True + + def _parse_radio(self, channel: XMChannel) -> Radio: + radio = Radio( + provider=self.instance_id, + item_id=channel.id, + name=channel.name, + provider_mappings={ + ProviderMapping( + provider_domain=self.domain, + provider_instance=self.instance_id, + item_id=channel.id, + ) + }, + ) + + icon = next((i.url for i in channel.images if i.width == 300 and i.height == 300), None) + banner = next( + (i.url for i in channel.images if i.name in ("channel hero image", "background")), None + ) + + images: list[MediaItemImage] = [] + + if icon is not None: + images.append( + MediaItemImage( + provider=self.instance_id, + type=ImageType.THUMB, + path=icon, + remotely_accessible=True, + ) + ) + images.append( + MediaItemImage( + provider=self.instance_id, + type=ImageType.LOGO, + path=icon, + remotely_accessible=True, + ) + ) + + if banner is not None: + images.append( + MediaItemImage( + provider=self.instance_id, + type=ImageType.BANNER, + path=banner, + remotely_accessible=True, + ) + ) + images.append( + MediaItemImage( + provider=self.instance_id, + type=ImageType.LANDSCAPE, + path=banner, + remotely_accessible=True, + ) + ) + + radio.metadata.images = images + radio.metadata.links = [MediaItemLink(type=LinkType.WEBSITE, url=channel.url)] + radio.metadata.description = channel.medium_description + radio.metadata.explicit = bool(channel.is_mature) + radio.metadata.genres = [cat.name for cat in channel.categories] + + return radio diff --git a/music_assistant/server/providers/siriusxm/icon.svg b/music_assistant/server/providers/siriusxm/icon.svg new file mode 100644 index 00000000..bb26dc74 --- /dev/null +++ b/music_assistant/server/providers/siriusxm/icon.svg @@ -0,0 +1,7 @@ + + SIRI_BIG copy-svg + + + diff --git a/music_assistant/server/providers/siriusxm/icon_dark.svg b/music_assistant/server/providers/siriusxm/icon_dark.svg new file mode 100644 index 00000000..fe3bcf3b --- /dev/null +++ b/music_assistant/server/providers/siriusxm/icon_dark.svg @@ -0,0 +1,7 @@ + + SIRI_BIG copy-svg + + + diff --git a/music_assistant/server/providers/siriusxm/manifest.json b/music_assistant/server/providers/siriusxm/manifest.json new file mode 100644 index 00000000..d1a36b58 --- /dev/null +++ b/music_assistant/server/providers/siriusxm/manifest.json @@ -0,0 +1,10 @@ +{ + "type": "music", + "domain": "siriusxm", + "name": "SiriusXM", + "description": "Support for the SiriusXM streaming radio provider in Music Assistant.", + "codeowners": ["@btoconnor"], + "requirements": ["sxm==0.2.8"], + "documentation": "https://music-assistant.io/music-providers/siriusxm/", + "multi_instance": false +} diff --git a/requirements_all.txt b/requirements_all.txt index aaf6199d..b325ce6c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -39,6 +39,7 @@ shortuuid==1.0.13 snapcast==2.3.6 soco==0.30.5 soundcloudpy==0.1.0 +sxm==0.2.8 tidalapi==0.8.0 unidecode==1.3.8 xmltodict==0.13.0