Add SomaFM Radio provider (#2981)
authorGarrett Mace <garrett@macetech.com>
Mon, 19 Jan 2026 08:07:52 +0000 (00:07 -0800)
committerGitHub <noreply@github.com>
Mon, 19 Jan 2026 08:07:52 +0000 (09:07 +0100)
* SomaFM provider initial release

* Polish playlist parsing per PR points (and remove MP3 format config)

music_assistant/providers/somafm/__init__.py [new file with mode: 0644]
music_assistant/providers/somafm/icon.svg [new file with mode: 0644]
music_assistant/providers/somafm/manifest.json [new file with mode: 0644]

diff --git a/music_assistant/providers/somafm/__init__.py b/music_assistant/providers/somafm/__init__.py
new file mode 100644 (file)
index 0000000..d3a3ad6
--- /dev/null
@@ -0,0 +1,238 @@
+"""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,
+        )
diff --git a/music_assistant/providers/somafm/icon.svg b/music_assistant/providers/somafm/icon.svg
new file mode 100644 (file)
index 0000000..f34a6da
--- /dev/null
@@ -0,0 +1,77 @@
+<?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
diff --git a/music_assistant/providers/somafm/manifest.json b/music_assistant/providers/somafm/manifest.json
new file mode 100644 (file)
index 0000000..95b6a1b
--- /dev/null
@@ -0,0 +1,14 @@
+{
+  "type": "music",
+  "domain": "somafm",
+  "stage": "beta",
+  "name": "SomaFM Radio",
+  "description": "Listen to SomaFM. Over 30 channels of commercial-free, human-curated radio programming focusing on non-mainstream music.",
+  "codeowners": ["@macegr"],
+  "credits": [
+    "[SomaFM Radio](https://somafm.com/)",
+    "Listener [donations](https://somafm.com/support) and [merchandise](https://somafm.com/store)"
+  ],
+  "requirements": [],
+  "multi_instance": false
+}