music-assistant-server

12.1 KBPY
__init__.py
12.1 KB357 lines • python
1"""SiriusXM Music Provider for Music Assistant."""
2
3from __future__ import annotations
4
5from collections.abc import AsyncGenerator, Sequence
6from typing import TYPE_CHECKING, Any
7
8from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
9from music_assistant_models.enums import (
10    ConfigEntryType,
11    ContentType,
12    ImageType,
13    LinkType,
14    MediaType,
15    ProviderFeature,
16    StreamType,
17)
18from music_assistant_models.errors import LoginFailed, MediaNotFoundError
19from music_assistant_models.media_items import (
20    AudioFormat,
21    BrowseFolder,
22    ItemMapping,
23    MediaItemImage,
24    MediaItemLink,
25    MediaItemType,
26    ProviderMapping,
27    Radio,
28    UniqueList,
29)
30from music_assistant_models.streamdetails import StreamDetails
31from tenacity import RetryError
32
33from music_assistant.controllers.cache import use_cache
34from music_assistant.helpers.util import select_free_port
35from music_assistant.helpers.webserver import Webserver
36from music_assistant.models.music_provider import MusicProvider
37
38if TYPE_CHECKING:
39    from music_assistant_models.config_entries import ProviderConfig
40    from music_assistant_models.provider import ProviderManifest
41
42    from music_assistant import MusicAssistant
43    from music_assistant.models import ProviderInstanceType
44
45import sxm.http
46from sxm import SXMClientAsync
47from sxm.models import QualitySize, RegionChoice, XMChannel, XMLiveChannel
48
49CONF_SXM_USERNAME = "sxm_email_address"
50CONF_SXM_PASSWORD = "sxm_password"
51CONF_SXM_REGION = "sxm_region"
52
53SUPPORTED_FEATURES = {
54    ProviderFeature.BROWSE,
55    ProviderFeature.LIBRARY_RADIOS,
56}
57
58
59async def setup(
60    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
61) -> ProviderInstanceType:
62    """Initialize provider(instance) with given configuration."""
63    return SiriusXMProvider(mass, manifest, config, SUPPORTED_FEATURES)
64
65
66async def get_config_entries(
67    mass: MusicAssistant,
68    instance_id: str | None = None,
69    action: str | None = None,
70    values: dict[str, ConfigValueType] | None = None,
71) -> tuple[ConfigEntry, ...]:
72    """
73    Return Config entries to setup this provider.
74
75    instance_id: id of an existing provider instance (None if new instance setup).
76    action: [optional] action key called from config entries UI.
77    values: the (intermediate) raw values for config entries sent with the action.
78    """
79    # ruff: noqa: ARG001
80    return (
81        ConfigEntry(
82            key=CONF_SXM_USERNAME,
83            type=ConfigEntryType.STRING,
84            label="Username",
85            required=True,
86        ),
87        ConfigEntry(
88            key=CONF_SXM_PASSWORD,
89            type=ConfigEntryType.SECURE_STRING,
90            label="Password",
91            required=True,
92        ),
93        ConfigEntry(
94            key=CONF_SXM_REGION,
95            type=ConfigEntryType.STRING,
96            default_value="US",
97            options=[
98                ConfigValueOption(title="United States", value="US"),
99                ConfigValueOption(title="Canada", value="CA"),
100            ],
101            label="Region",
102            required=True,
103        ),
104    )
105
106
107class SiriusXMProvider(MusicProvider):
108    """SiriusXM Music Provider."""
109
110    _username: str
111    _password: str
112    _region: str
113    _client: SXMClientAsync
114
115    _channels: list[XMChannel]
116
117    _sxm_server: Webserver
118    _base_url: str
119
120    _current_stream_details: StreamDetails | None = None
121
122    async def handle_async_init(self) -> None:
123        """Handle async initialization of the provider."""
124        username = self.config.get_value(CONF_SXM_USERNAME)
125        assert isinstance(username, str)  # for type checker
126        password = self.config.get_value(CONF_SXM_PASSWORD)
127        assert isinstance(password, str)  # for type checker
128
129        region: RegionChoice = (
130            RegionChoice.US if self.config.get_value(CONF_SXM_REGION) == "US" else RegionChoice.CA
131        )
132
133        self._client = SXMClientAsync(
134            username,
135            password,
136            region,
137            quality=QualitySize.LARGE_256k,
138            update_handler=self._channel_updated,
139        )
140
141        self.logger.info("Authenticating with SiriusXM")
142        try:
143            if not await self._client.authenticate():
144                raise LoginFailed("Could not login to SiriusXM")
145        except RetryError:
146            # It looks like there's a bug in the sxm-client code
147            # where it won't return False if there's bad credentials.
148            # Due to the retry logic, it's attempting to log in multiple
149            # times and then finally raises an unrelated exception,
150            # rather than returning False or raising the package's
151            # AuthenticationError.
152            # Therefore, we're resorting to catching the RetryError
153            # here and recognizing it as a login failure.
154            raise LoginFailed("Could not login to SiriusXM")
155
156        self.logger.info("Successfully authenticated")
157
158        await self._refresh_channels()
159
160        # Set up the sxm server for streaming
161        bind_ip = "127.0.0.1"
162        bind_port = await select_free_port(8100, 9999)
163
164        self._base_url = f"{bind_ip}:{bind_port}"
165        http_handler = sxm.http.make_http_handler(self._client)
166
167        self._sxm_server = Webserver(self.logger)
168
169        await self._sxm_server.setup(
170            bind_ip=bind_ip,
171            bind_port=bind_port,
172            base_url=self._base_url,
173            static_routes=[
174                ("*", "/{tail:.*}", http_handler),
175            ],
176        )
177
178        self.logger.debug(f"SXM Proxy server running at {bind_ip}:{bind_port}")
179
180    async def unload(self, is_removed: bool = False) -> None:
181        """
182        Handle unload/close of the provider.
183
184        Called when provider is deregistered (e.g. MA exiting or config reloading).
185        """
186        await self._sxm_server.close()
187
188    @property
189    def is_streaming_provider(self) -> bool:
190        """
191        Return True if the provider is a streaming provider.
192
193        This literally means that the catalog is not the same as the library contents.
194        For local based providers (files, plex), the catalog is the same as the library content.
195        It also means that data is if this provider is NOT a streaming provider,
196        data cross instances is unique, the catalog and library differs per instance.
197
198        Setting this to True will only query one instance of the provider for search and lookups.
199        Setting this to False will query all instances of this provider for search and lookups.
200        """
201        return True
202
203    async def get_library_radios(self) -> AsyncGenerator[Radio, None]:
204        """Retrieve library/subscribed radio stations from the provider."""
205        for channel in self._channels_by_id.values():
206            if channel.is_favorite:
207                yield self._parse_radio(channel)
208
209    @use_cache(3600 * 24 * 14)  # Cache for 14 days
210    async def get_radio(self, prov_radio_id: str) -> Radio:
211        """Get full radio details by id."""
212        if prov_radio_id not in self._channels_by_id:
213            raise MediaNotFoundError("Station not found")
214
215        return self._parse_radio(self._channels_by_id[prov_radio_id])
216
217    async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
218        """Get streamdetails for a track/radio."""
219        # There's a chance that the SiriusXM auth session has expired
220        # by the time the user clicks to play a station.  The sxm-client
221        # will attempt to reauthenticate automatically, but this causes
222        # a delay in streaming, and ffmpeg raises a TimeoutError.
223        # To prevent this, we're going to explicitly authenticate with
224        # SiriusXM proactively when a station has been chosen to avoid
225        # this.
226        await self._client.authenticate()
227
228        hls_path = f"http://{self._base_url}/{item_id}.m3u8"
229
230        # Keep a reference to the current `StreamDetails` object so that we can
231        # update the `stream_title` attribute as callbacks come in from the
232        # sxm-client with the channel's live data.
233        # See `_channel_updated` for where this is handled.
234        self._current_stream_details = StreamDetails(
235            item_id=item_id,
236            provider=self.instance_id,
237            audio_format=AudioFormat(
238                content_type=ContentType.AAC,
239            ),
240            stream_type=StreamType.HLS,
241            media_type=MediaType.RADIO,
242            path=hls_path,
243            can_seek=False,
244            allow_seek=False,
245        )
246
247        return self._current_stream_details
248
249    @use_cache(3600 * 3)  # Cache for 3 hours
250    async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
251        """Browse this provider's items.
252
253        :param path: The path to browse, (e.g. provider_id://artists).
254        """
255        return [self._parse_radio(channel) for channel in self._channels]
256
257    def _channel_updated(self, live_channel_raw: dict[str, Any]) -> None:
258        """Handle a channel update event."""
259        live_data = XMLiveChannel.from_dict(live_channel_raw)
260
261        self.logger.debug(f"Got update for SiriusXM channel {live_data.id}")
262
263        if self._current_stream_details is None:
264            return
265
266        current_channel = self._current_stream_details.item_id
267
268        if live_data.id != current_channel:
269            # This can happen when changing channels
270            self.logger.debug(
271                f"Received update for channel {live_data.id}, current channel is {current_channel}"
272            )
273            return
274
275        latest_cut_marker = live_data.get_latest_cut()
276
277        if latest_cut_marker:
278            latest_cut = latest_cut_marker.cut
279            title = latest_cut.title
280            artist = ", ".join([a.name for a in latest_cut.artists])
281            self._current_stream_details.stream_title = f"{artist} - {title}"
282
283    async def _refresh_channels(self) -> bool:
284        self._channels = await self._client.channels
285
286        self._channels_by_id = {}
287
288        for channel in self._channels:
289            self._channels_by_id[channel.id] = channel
290
291        return True
292
293    def _parse_radio(self, channel: XMChannel) -> Radio:
294        radio = Radio(
295            provider=self.instance_id,
296            item_id=channel.id,
297            name=channel.name,
298            provider_mappings={
299                ProviderMapping(
300                    provider_domain=self.domain,
301                    provider_instance=self.instance_id,
302                    item_id=channel.id,
303                )
304            },
305        )
306
307        icon = next((i.url for i in channel.images if i.width == 300 and i.height == 300), None)
308        banner = next(
309            (i.url for i in channel.images if i.name in ("channel hero image", "background")), None
310        )
311
312        images: list[MediaItemImage] = []
313
314        if icon is not None:
315            images.append(
316                MediaItemImage(
317                    provider=self.instance_id,
318                    type=ImageType.THUMB,
319                    path=icon,
320                    remotely_accessible=True,
321                )
322            )
323            images.append(
324                MediaItemImage(
325                    provider=self.instance_id,
326                    type=ImageType.LOGO,
327                    path=icon,
328                    remotely_accessible=True,
329                )
330            )
331
332        if banner is not None:
333            images.append(
334                MediaItemImage(
335                    provider=self.instance_id,
336                    type=ImageType.BANNER,
337                    path=banner,
338                    remotely_accessible=True,
339                )
340            )
341            images.append(
342                MediaItemImage(
343                    provider=self.instance_id,
344                    type=ImageType.LANDSCAPE,
345                    path=banner,
346                    remotely_accessible=True,
347                )
348            )
349
350        radio.metadata.images = UniqueList(images) if images else None
351        radio.metadata.links = {MediaItemLink(type=LinkType.WEBSITE, url=channel.url)}
352        radio.metadata.description = channel.medium_description
353        radio.metadata.explicit = bool(channel.is_mature)
354        radio.metadata.genres = {cat.name for cat in channel.categories}
355
356        return radio
357