--- /dev/null
+"""SomaFM Radio music provider support for MusicAssistant."""
+
+from __future__ import annotations
+
+import random
+from typing import TYPE_CHECKING, Any
+
+from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
+from music_assistant_models.enums import (
+ ConfigEntryType,
+ ContentType,
+ ImageType,
+ MediaType,
+ ProviderFeature,
+ StreamType,
+)
+from music_assistant_models.errors import MediaNotFoundError
+from music_assistant_models.media_items import (
+ AudioFormat,
+ MediaItemImage,
+ MediaItemMetadata,
+ ProviderMapping,
+ Radio,
+)
+from music_assistant_models.streamdetails import StreamDetails
+
+from music_assistant.controllers.cache import use_cache
+from music_assistant.helpers.playlists import PlaylistItem, fetch_playlist
+from music_assistant.models.music_provider import MusicProvider
+
+if TYPE_CHECKING:
+ from collections.abc import AsyncGenerator
+
+ from music_assistant_models.config_entries import ProviderConfig
+ from music_assistant_models.provider import ProviderManifest
+
+ from music_assistant import MusicAssistant
+ from music_assistant.models import ProviderInstanceType
+
+SUPPORTED_FEATURES = {
+ ProviderFeature.LIBRARY_RADIOS,
+ ProviderFeature.BROWSE,
+}
+
+CONF_QUALITY = "quality"
+
+
+async def setup(
+ mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+ """Initialize provider(instance) with given configuration."""
+ return SomaFMProvider(mass, manifest, config, SUPPORTED_FEATURES)
+
+
+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."""
+ # ruff: noqa: ARG001
+ return (
+ ConfigEntry(
+ key=CONF_QUALITY,
+ category="advanced",
+ type=ConfigEntryType.STRING,
+ label="Stream Quality",
+ options=[
+ ConfigValueOption("Highest", "highest"),
+ ConfigValueOption("High", "high"),
+ ConfigValueOption("Low", "low"),
+ ],
+ default_value="highest",
+ ),
+ )
+
+
+class SomaFMProvider(MusicProvider):
+ """Provider implementation for SomaFM Radio."""
+
+ @property
+ def is_streaming_provider(self) -> bool:
+ """Return True if the provider is a streaming provider."""
+ return True
+
+ async def get_library_radios(self) -> AsyncGenerator[Radio, None]:
+ """Retrieve library/subscribed radio stations from the provider."""
+ stations = await self._get_stations() # May be cached
+ if stations:
+ for channel_info in stations.values():
+ radio = self._parse_channel(channel_info)
+ yield radio
+
+ async def get_radio(self, prov_radio_id: str) -> Radio:
+ """Get radio station details."""
+ stations = await self._get_stations() # May be cached
+ if stations:
+ radio = stations.get(prov_radio_id)
+ if radio:
+ return self._parse_channel(radio)
+ msg = f"Item {prov_radio_id} not found"
+ raise MediaNotFoundError(msg)
+
+ @use_cache(3600 * 24 * 1) # Cache for 1 day
+ async def _get_stations(self) -> dict[str, dict[str, Any]]:
+ url = "https://somafm.com/channels.json"
+ locale = self.mass.metadata.locale.replace("_", "-")
+ language = locale.split("-")[0]
+ headers = {"Accept-Language": f"{locale}, {language};q=0.9, *;q=0.5"}
+ async with (
+ self.mass.http_session.get(url, headers=headers, ssl=False) as response,
+ ):
+ result: Any = await response.json()
+ if not result or "error" in result:
+ self.logger.error(url)
+ elif isinstance(result, dict):
+ stations = result.get("channels")
+ if stations:
+ # Reformat into dict by channel id
+ return {info.get("id"): info for info in stations if info.get("id")}
+ raise MediaNotFoundError("Could not fetch SomaFM stations list")
+
+ def _parse_channel(self, channel_info: dict[str, Any]) -> Radio:
+ """Convert SomaFM channel info into a Radio object."""
+ # Construct radio station information
+ item_id = channel_info.get("id")
+ if not item_id:
+ raise MediaNotFoundError("Soma FM station generation failed")
+
+ radio = Radio(
+ provider=self.instance_id,
+ item_id=item_id,
+ name=f"SomaFM: {channel_info.get('title', 'Unknown Radio')}",
+ metadata=MediaItemMetadata(
+ description=channel_info.get("description", "No description"),
+ genres={channel_info.get("genre", "No genre")},
+ popularity=int(channel_info.get("listeners", "0")),
+ performers={
+ f"DJ: {channel_info.get('dj', 'No DJ info')}",
+ f"DJ Email: {channel_info.get('djmail', 'No DJ email')}",
+ },
+ ),
+ provider_mappings={
+ ProviderMapping(
+ provider_domain=self.domain,
+ provider_instance=self.instance_id,
+ item_id=item_id,
+ available=True,
+ )
+ },
+ )
+
+ # Add station image URL
+ station_icon_url = channel_info.get("largeimage")
+ if station_icon_url:
+ radio.metadata.add_image(
+ MediaItemImage(
+ provider=self.instance_id,
+ type=ImageType.THUMB,
+ path=station_icon_url,
+ remotely_accessible=True,
+ )
+ )
+ return radio
+
+ async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
+ """Get stream details for a track/radio."""
+
+ async def _get_valid_playlist_item(playlist: list[PlaylistItem]) -> PlaylistItem:
+ """Randomly select stream URL from playlist and test it."""
+ random.shuffle(playlist)
+ for item in playlist:
+ async with self.mass.http_session.head(item.path, ssl=False) as response:
+ if response.status >= 100 and response.status < 300:
+ # Stream exists, return valid path
+ return item
+ self.logger.error("Could not find a working stream for playlist")
+ raise MediaNotFoundError("No valid SomaFM stream available")
+
+ def _get_playlist_url(station: dict[str, Any]) -> str:
+ """Pick playlist based on quality config value."""
+ req_quality = self.config.get_value(CONF_QUALITY)
+ playlists: list[dict[str, str]] = station.get("playlists", [])
+
+ # Remove MP3 playlist options for now; AAC is generally better
+ playlists = [
+ playlist for playlist in playlists if playlist["format"] in {"aac", "aacp"}
+ ]
+
+ # Sort by quality just in case they already aren't sorted highest/high/low
+ quality_map = {"highest": 0, "high": 1, "low": 2}
+ playlists.sort(key=lambda x: quality_map[x["quality"]])
+
+ # Detect empty playlist after sort and filter
+ if len(playlists) == 0:
+ raise MediaNotFoundError("No valid SomaFM playlist available")
+
+ # Find the first playlist item that has the requested quality
+ for playlist in playlists:
+ avail_quality = playlist.get("quality")
+ playlist_url = playlist.get("url")
+ if req_quality == avail_quality and playlist_url:
+ return playlist_url
+
+ self.logger.warning("Couldn't find SomaFM stream with requested quality and format")
+
+ # Get the first (highest quality) playlist if we couldn't find requested quality
+ playlist_url = playlists[0].get("url")
+ if playlist_url:
+ return playlist_url
+ raise MediaNotFoundError("No valid SomaFM playlist available")
+
+ async def _get_stream_path(item_id: str) -> str:
+ """Pick correct playlist, fetch the playlist, and extract stream URL."""
+ stations = await self._get_stations()
+ station = stations.get(item_id)
+ if station:
+ playlist_url = _get_playlist_url(station)
+ playlist = await fetch_playlist(self.mass, playlist_url)
+ playlist_item: PlaylistItem = await _get_valid_playlist_item(playlist)
+ return playlist_item.path
+ raise MediaNotFoundError
+
+ stream_path = await _get_stream_path(item_id)
+
+ return StreamDetails(
+ provider=self.instance_id,
+ item_id=item_id,
+ audio_format=AudioFormat(
+ content_type=ContentType.UNKNOWN,
+ ),
+ media_type=MediaType.RADIO,
+ path=stream_path,
+ stream_type=StreamType.HTTP,
+ allow_seek=False,
+ can_seek=False,
+ )
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>\r
+<!-- Generator: Adobe Illustrator 21.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->\r
+<svg version="1.1" id="Calque_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"\r
+ viewBox="0 0 550 154.9499969" style="enable-background:new 0 0 550 154.9499969;" xml:space="preserve">\r
+<style type="text/css">\r
+ .st0{fill:#FF0000;}\r
+</style>\r
+<g>\r
+ <g>\r
+ <path class="st0" d="M48.3538933,66.8663559c-2.8481979,0-7.6897125-0.569519-7.6897125,3.8445511\r
+ c0,2.2780762,1.7085571,3.1329575,3.7024765,3.987236l34.1753502,15.5214996\r
+ c8.8287506,3.9878387,13.3855057,9.5403442,13.3855057,19.3660507c0,13.8135452-9.3982697,20.3636169-22.4990158,20.3636169\r
+ h-21.074604c-7.1201935,0-11.9617081-0.2853699-17.0879784-4.2719955C28.7024765,123.6833954,25,121.119957,25,117.7028503\r
+ c0-3.8451614,3.2756386-6.835434,7.1201935-6.835434c2.1359978,0,3.7024727,0.7121964,5.2689514,2.4207535\r
+ c2.4207573,2.7061234,6.6927528,2.9902725,10.9647484,2.9902725h18.511776c4.841507,0,11.3915787,0.4280472,11.3915787-7.5464249\r
+ c0-3.2756348-3.987236-5.411026-7.1195908-6.8354263L38.9550209,87.2293701\r
+ c-7.5470295-3.4171143-11.9611015-7.8323975-11.9611015-16.6611404c0-10.8220673,8.5439911-17.3721352,18.7965355-17.3721352\r
+ h23.3532829c7.5470352,0,10.6799927,0.4268341,15.8062592,4.6988297c2.5634384,2.1359978,5.4110336,3.8451576,5.4110336,7.6897087\r
+ c0,3.8445587-3.275032,6.8348312-6.9775085,6.8348312c-2.5628357,0-4.1293106-0.9963531-5.8384781-2.8475952\r
+ c-2.2780685-2.4207535-4.6988297-2.705513-8.4013062-2.705513H48.3538933z"/>\r
+ <path class="st0" d="M104.7374496,78.685379c0-9.1135101,1.8512344-12.2464676,8.6866684-17.8001785\r
+ c6.692749-5.5531082,9.8251038-7.689106,19.3660583-7.689106h10.822052c9.5409546,0,12.6739044,2.1359978,19.3666687,7.689106\r
+ c6.8348236,5.5537109,8.6860657,8.6866684,8.6860657,17.8001785v25.7746506\r
+ c0,8.9708252-2.2780762,12.3879318-9.3982697,18.3684845c-7.1201935,5.9817581-9.9677887,7.1207962-18.6544647,7.1207962\r
+ h-10.822052c-8.6860733,0-11.5342712-1.1390381-18.6544647-7.1207962\r
+ c-7.1195831-5.9805527-9.398262-9.3976593-9.398262-18.3684845V78.685379z M118.4077072,104.4600296\r
+ c0,2.8475876,0.9969635,4.6988297,4.9841995,8.1159363c2.9902725,2.563446,5.1262665,3.7024765,9.3982697,3.7024765h10.822052\r
+ c4.2720032,0,6.4079895-1.1390305,9.3982697-3.7024765c3.9872437-3.4171066,4.9841919-5.2683487,4.9841919-8.1159363V78.685379\r
+ c0-2.8481979-0.9969482-4.69944-4.9841919-8.1171494c-2.9902802-2.5628357-5.1262665-3.7018738-9.3982697-3.7018738h-10.822052\r
+ c-4.2720032,0-6.4079971,1.1390381-9.3982697,3.7018738c-3.987236,3.4177094-4.9841995,5.2689514-4.9841995,8.1171494V104.4600296\r
+ z"/>\r
+ <path class="st0" d="M224.7734833,76.1219406v44.4284973c0,4.6988297-0.9963684,9.3988724-6.8342285,9.3988724\r
+ c-5.8390808,0-6.835434-4.7000427-6.835434-9.3988724V74.8402176c0-2.2780685,0.2841492-6.5500641-3.2756348-6.5500641\r
+ c-1.5658722,0-2.563446,1.2817154-3.7024841,2.2780762l-5.980545,5.5537109v44.4284973\r
+ c0,4.6988297-0.9963531,9.3988724-6.8348236,9.3988724c-5.8384857,0-6.835434-4.7000427-6.835434-9.3988724V62.8791199\r
+ c0-4.8415146,1.2817078-9.6830254,7.1201935-9.6830254c3.4177094,0,5.6963959,2.2780724,6.5500641,5.4110298l1.1390381-0.8542786\r
+ c3.5597839-2.705513,5.6963959-4.5567513,10.3952179-4.5567513c5.2695618,0,9.39888,2.5628319,12.5318298,6.8348274\r
+ l1.1390381-1.1390381c4.2720032-4.2719917,5.9805603-5.6957893,11.9611053-5.6957893\r
+ c4.5573578,0,8.686676,1.9933128,11.3915863,5.5531082c3.4183197,4.4146767,3.1329498,8.4019127,3.2756348,13.6702614\r
+ l1.424408,47.2773056c0.1414642,4.841507-0.5707397,10.2525406-6.6927643,10.2525406\r
+ c-5.5537109,0-6.692749-4.7000427-6.835434-9.2561874l-1.4243927-45.1407013\r
+ c-0.1426849-2.4207535,0.569519-7.2622681-3.1329651-7.2622681c-1.9927063,0-4.5561371,3.417717-5.6951752,4.6988297\r
+ L224.7734833,76.1219406z"/>\r
+ <path class="st0" d="M287.1381836,66.8663559c-3.9860229,0-9.3982544-0.8548813-9.3982544-6.835434\r
+ c0-5.8384743,4.8414917-6.8348274,9.3982544-6.8348274h18.7965393c7.1195984,0,12.24646,0.7115936,17.6575012,6.12323\r
+ c5.2683411,5.2683525,5.9805298,9.3982658,6.12323,16.2330971l1.424408,46.4224167\r
+ c0.141449,3.9866333-1.8512573,7.974472-6.4085999,7.974472c-3.5598145,0-7.1195984-2.9902725-6.9769287-6.835434\r
+ l-9.3988647,4.8415146c-3.8451538,1.9939194-3.9866333,1.9939194-8.4013062,1.9939194h-13.1013489\r
+ c-6.1213989,0-10.5360718-0.712204-15.5202942-4.8415146c-5.6963806-4.7000351-7.1195679-9.6830215-7.1195679-16.8038177\r
+ v-5.9805527c0-6.5500717,1.5658569-10.6793823,6.692749-15.2367401c4.698822-4.1293106,9.9671631-5.1262741,15.947113-5.1262741\r
+ h29.3344421v-3.1329575c0-10.2525406-2.2780762-11.9610977-12.1037598-11.9610977H287.1381836z M286.9955139,95.6306763\r
+ c-3.7018738,0-9.112915,0.9975586-9.112915,5.980545v9.1135101c0,4.5573578,5.5537109,5.5537109,8.8287659,5.5537109h15.0940552\r
+ l15.0940552-7.8317871v-2.9902725c0-7.5476379-2.1365967-9.8257065-9.6830444-9.8257065H286.9955139z"/>\r
+ </g>\r
+ <g>\r
+ <path class="st0" d="M409.789032,66.8663559h-3.8451538c-3.9866333,0-9.397644-0.8548813-9.397644-6.835434\r
+ c0-5.8384743,4.8414917-6.8348274,9.397644-6.8348274h3.8451538v-1.8512383c0-7.8317871,0.4268494-13.528183,6.5500793-19.5087337\r
+ c6.5500793-6.2653103,12.6733093-6.835434,21.0746155-6.835434h9.2561951c4.698822,0,9.9683838,0.4274406,9.9683838,6.835434\r
+ c0,5.8384724-4.698822,6.8348293-9.3988647,6.8348293h-10.6793823c-9.2561951,0-13.1013489,1.8512344-13.1013489,11.8196259\r
+ v2.7055168h12.1037598c4.557373,0,9.3988953,0.9963531,9.3988953,6.8348274c0,5.9805527-5.4110413,6.835434-9.3988953,6.835434\r
+ h-12.1037598v53.684082c0,4.6988297-0.9963684,9.3988724-6.8342285,9.3988724\r
+ c-5.8390808,0-6.8354492-4.7000427-6.8354492-9.3988724V66.8663559z"/>\r
+ <path class="st0" d="M498.355835,76.1219406v44.4284973c0,4.6988297-0.9975586,9.3988724-6.8354187,9.3988724\r
+ c-5.8390808,0-6.8354492-4.7000427-6.8354492-9.3988724V74.8402176c0-2.2780685,0.2853699-6.5500641-3.2744141-6.5500641\r
+ c-1.5670776,0-2.563446,1.2817154-3.7024841,2.2780762l-5.9817505,5.5537109v44.4284973\r
+ c0,4.6988297-0.9963684,9.3988724-6.8342285,9.3988724c-5.8390808,0-6.8354187-4.7000427-6.8354187-9.3988724V62.8791199\r
+ c0-4.8415146,1.2817078-9.6830254,7.1195679-9.6830254c3.418335,0,5.6964111,2.2780724,6.5500793,5.4110298l1.1402283-0.8542786\r
+ c3.5598145-2.705513,5.6951904-4.5567513,10.394043-4.5567513c5.2695618,0,9.3988647,2.5628319,12.5318298,6.8348274\r
+ l1.1390381-1.1390381c4.2719727-4.2719917,5.9805298-5.6957893,11.9610901-5.6957893\r
+ c4.5573425,0,8.686676,1.9933128,11.3927917,5.5531082c3.4171143,4.4146767,3.1317139,8.4019127,3.2744141,13.6702614\r
+ l1.4244385,47.2773056c0.1426392,4.841507-0.569519,10.2525406-6.692749,10.2525406\r
+ c-5.5537109,0-6.6927795-4.7000427-6.8354492-9.2561874l-1.424408-45.1407013\r
+ c-0.1414795-2.4207535,0.569519-7.2622681-3.1317444-7.2622681c-1.993927,0-4.557373,3.417717-5.6964111,4.6988297\r
+ L498.355835,76.1219406z"/>\r
+ </g>\r
+</g>\r
+</svg>\r