Add SiriusXM Music Provider (#1730)
authorBrian O'Connor <btoconnor@users.noreply.github.com>
Sun, 20 Oct 2024 19:48:16 +0000 (15:48 -0400)
committerGitHub <noreply@github.com>
Sun, 20 Oct 2024 19:48:16 +0000 (21:48 +0200)
music_assistant/server/providers/siriusxm/__init__.py [new file with mode: 0644]
music_assistant/server/providers/siriusxm/icon.svg [new file with mode: 0644]
music_assistant/server/providers/siriusxm/icon_dark.svg [new file with mode: 0644]
music_assistant/server/providers/siriusxm/manifest.json [new file with mode: 0644]
requirements_all.txt

diff --git a/music_assistant/server/providers/siriusxm/__init__.py b/music_assistant/server/providers/siriusxm/__init__.py
new file mode 100644 (file)
index 0000000..6e94331
--- /dev/null
@@ -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 (file)
index 0000000..bb26dc7
--- /dev/null
@@ -0,0 +1,7 @@
+<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1536 1542" width="24" height="25">
+       <title>SIRI_BIG copy-svg</title>
+       <style>
+               .s0 { fill: #0000eb }
+       </style>
+       <path id="Layer" class="s0" d="m1477.8 829.2c36.1 54.7 57.3 121 57.3 203.4-2.5 303.2-253.7 508.5-794.6 508.5-338 0-709.6-103-740.5-495v-19.3h506.8l-112.1 330.2 369.6-256.8 369.7 256.8-96-283.9c-14.8-28.9-53.4-45-53.4-45-202.2-112-938.9-51.5-938.9-546.6 0-372.7 392.2-481.5 699.3-481.5 321.4 0 698.7 72.7 732.2 459v14.8h-570.5l-141.1-415.2-141.6 415.2h-457.2l255.6 175.7c74.7-29.6 154.6-42.4 234.4-51.5 85.7-9 171.9-10.9 258.2-5.1 72.1 4.5 144.3 14.8 215.1 31.5 63.8 14.8 128.8 31.6 186.7 63.1 65.1 36.1 121.7 81.1 161 141.7z"/>
+</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 (file)
index 0000000..fe3bcf3
--- /dev/null
@@ -0,0 +1,7 @@
+<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1536 1542" width="24" height="25">
+       <title>SIRI_BIG copy-svg</title>
+       <style>
+               .s0 { fill: #ffffff; }
+       </style>
+       <path id="Layer" class="s0" d="m1477.8 829.2c36.1 54.7 57.3 121 57.3 203.4-2.5 303.2-253.7 508.5-794.6 508.5-338 0-709.6-103-740.5-495v-19.3h506.8l-112.1 330.2 369.6-256.8 369.7 256.8-96-283.9c-14.8-28.9-53.4-45-53.4-45-202.2-112-938.9-51.5-938.9-546.6 0-372.7 392.2-481.5 699.3-481.5 321.4 0 698.7 72.7 732.2 459v14.8h-570.5l-141.1-415.2-141.6 415.2h-457.2l255.6 175.7c74.7-29.6 154.6-42.4 234.4-51.5 85.7-9 171.9-10.9 258.2-5.1 72.1 4.5 144.3 14.8 215.1 31.5 63.8 14.8 128.8 31.6 186.7 63.1 65.1 36.1 121.7 81.1 161 141.7z"/>
+</svg>
diff --git a/music_assistant/server/providers/siriusxm/manifest.json b/music_assistant/server/providers/siriusxm/manifest.json
new file mode 100644 (file)
index 0000000..d1a36b5
--- /dev/null
@@ -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
+}
index aaf6199df4a47ac8ddea0f3815a0e5b0d74b9068..b325ce6c0e902a5b7c693ae01561074770061d27 100644 (file)
@@ -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