From d839febbe7520253d10603c1754c6fcc25ca9872 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 21 Feb 2022 18:13:02 +0100 Subject: [PATCH] Add Radio Browser integration (#66950) --- .coveragerc | 2 + CODEOWNERS | 2 + homeassistant/components/onboarding/views.py | 17 +- .../components/radio_browser/__init__.py | 36 +++ .../components/radio_browser/config_flow.py | 33 ++ .../components/radio_browser/const.py | 7 + .../components/radio_browser/manifest.json | 9 + .../components/radio_browser/media_source.py | 286 ++++++++++++++++++ .../components/radio_browser/strings.json | 12 + .../radio_browser/translations/en.json | 12 + homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/onboarding/test_views.py | 17 ++ tests/components/radio_browser/__init__.py | 1 + tests/components/radio_browser/conftest.py | 30 ++ .../radio_browser/test_config_flow.py | 65 ++++ 17 files changed, 531 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/radio_browser/__init__.py create mode 100644 homeassistant/components/radio_browser/config_flow.py create mode 100644 homeassistant/components/radio_browser/const.py create mode 100644 homeassistant/components/radio_browser/manifest.json create mode 100644 homeassistant/components/radio_browser/media_source.py create mode 100644 homeassistant/components/radio_browser/strings.json create mode 100644 homeassistant/components/radio_browser/translations/en.json create mode 100644 tests/components/radio_browser/__init__.py create mode 100644 tests/components/radio_browser/conftest.py create mode 100644 tests/components/radio_browser/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index da4bdaede00..1b48888ac41 100644 --- a/.coveragerc +++ b/.coveragerc @@ -955,6 +955,8 @@ omit = homeassistant/components/rachio/switch.py homeassistant/components/rachio/webhooks.py homeassistant/components/radarr/sensor.py + homeassistant/components/radio_browser/__init__.py + homeassistant/components/radio_browser/media_source.py homeassistant/components/radiotherm/climate.py homeassistant/components/rainbird/* homeassistant/components/raincloud/* diff --git a/CODEOWNERS b/CODEOWNERS index d80ca44f894..a8d24fa03a3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -755,6 +755,8 @@ homeassistant/components/qwikswitch/* @kellerza tests/components/qwikswitch/* @kellerza homeassistant/components/rachio/* @bdraco tests/components/rachio/* @bdraco +homeassistant/components/radio_browser/* @frenck +tests/components/radio_browser/* @frenck homeassistant/components/radiotherm/* @vinnyfuria homeassistant/components/rainbird/* @konikvranik homeassistant/components/raincloud/* @vanstinator diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 0d041463d11..b277bd97edf 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -188,9 +188,8 @@ class CoreConfigOnboardingView(_BaseOnboardingView): await self._async_mark_done(hass) - await hass.config_entries.flow.async_init( - "met", context={"source": "onboarding"} - ) + # Integrations to set up when finishing onboarding + onboard_integrations = ["met", "radio_browser"] # pylint: disable=import-outside-toplevel from homeassistant.components import hassio @@ -199,9 +198,17 @@ class CoreConfigOnboardingView(_BaseOnboardingView): hassio.is_hassio(hass) and "raspberrypi" in hassio.get_core_info(hass)["machine"] ): - await hass.config_entries.flow.async_init( - "rpi_power", context={"source": "onboarding"} + onboard_integrations.append("rpi_power") + + # Set up integrations after onboarding + await asyncio.gather( + *( + hass.config_entries.flow.async_init( + domain, context={"source": "onboarding"} + ) + for domain in onboard_integrations ) + ) return self.json({}) diff --git a/homeassistant/components/radio_browser/__init__.py b/homeassistant/components/radio_browser/__init__.py new file mode 100644 index 00000000000..89c2f220159 --- /dev/null +++ b/homeassistant/components/radio_browser/__init__.py @@ -0,0 +1,36 @@ +"""The Radio Browser integration.""" +from __future__ import annotations + +from radios import RadioBrowser, RadioBrowserError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import __version__ +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Radio Browser from a config entry. + + This integration doesn't set up any enitites, as it provides a media source + only. + """ + session = async_get_clientsession(hass) + radios = RadioBrowser(session=session, user_agent=f"HomeAssistant/{__version__}") + + try: + await radios.stats() + except RadioBrowserError as err: + raise ConfigEntryNotReady("Could not connect to Radio Browser API") from err + + hass.data[DOMAIN] = radios + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + del hass.data[DOMAIN] + return True diff --git a/homeassistant/components/radio_browser/config_flow.py b/homeassistant/components/radio_browser/config_flow.py new file mode 100644 index 00000000000..1c6964d0715 --- /dev/null +++ b/homeassistant/components/radio_browser/config_flow.py @@ -0,0 +1,33 @@ +"""Config flow for Radio Browser integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigFlow +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + + +class RadioBrowserConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Radio Browser.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + if user_input is not None: + return self.async_create_entry(title="Radio Browser", data={}) + + return self.async_show_form(step_id="user") + + async def async_step_onboarding( + self, data: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by onboarding.""" + return self.async_create_entry(title="Radio Browser", data={}) diff --git a/homeassistant/components/radio_browser/const.py b/homeassistant/components/radio_browser/const.py new file mode 100644 index 00000000000..eb456db08e8 --- /dev/null +++ b/homeassistant/components/radio_browser/const.py @@ -0,0 +1,7 @@ +"""Constants for the Radio Browser integration.""" +import logging +from typing import Final + +DOMAIN: Final = "radio_browser" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/radio_browser/manifest.json b/homeassistant/components/radio_browser/manifest.json new file mode 100644 index 00000000000..865d8b25ab1 --- /dev/null +++ b/homeassistant/components/radio_browser/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "radio_browser", + "name": "Radio Browser", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/radio", + "requirements": ["radios==0.1.0"], + "codeowners": ["@frenck"], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/radio_browser/media_source.py b/homeassistant/components/radio_browser/media_source.py new file mode 100644 index 00000000000..952b0e67e25 --- /dev/null +++ b/homeassistant/components/radio_browser/media_source.py @@ -0,0 +1,286 @@ +"""Expose Radio Browser as a media source.""" +from __future__ import annotations + +import mimetypes + +from radios import FilterBy, Order, RadioBrowser, Station + +from homeassistant.components.media_player.const import ( + MEDIA_CLASS_CHANNEL, + MEDIA_CLASS_DIRECTORY, + MEDIA_CLASS_MUSIC, + MEDIA_TYPE_MUSIC, +) +from homeassistant.components.media_player.errors import BrowseError +from homeassistant.components.media_source.models import ( + BrowseMediaSource, + MediaSource, + MediaSourceItem, + PlayMedia, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN + +CODEC_TO_MIMETYPE = { + "MP3": "audio/mpeg", + "AAC": "audio/aac", + "AAC+": "audio/aac", + "OGG": "application/ogg", +} + + +async def async_get_media_source(hass: HomeAssistant) -> RadioMediaSource: + """Set up Radio Browser media source.""" + # Radio browser support only a single config entry + entry = hass.config_entries.async_entries(DOMAIN)[0] + radios = hass.data[DOMAIN] + + return RadioMediaSource(hass, radios, entry) + + +class RadioMediaSource(MediaSource): + """Provide Radio stations as media sources.""" + + def __init__( + self, hass: HomeAssistant, radios: RadioBrowser, entry: ConfigEntry + ) -> None: + """Initialize CameraMediaSource.""" + super().__init__(DOMAIN) + self.hass = hass + self.entry = entry + self.radios = radios + + async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: + """Resolve selected Radio station to a streaming URL.""" + station = await self.radios.station(uuid=item.identifier) + if not station: + raise BrowseError("Radio station is no longer available") + + if not (mime_type := self._async_get_station_mime_type(station)): + raise BrowseError("Could not determine stream type of radio station") + + # Register "click" with Radio Browser + await self.radios.station_click(uuid=station.uuid) + + return PlayMedia(station.url, mime_type) + + async def async_browse_media( + self, + item: MediaSourceItem, + ) -> BrowseMediaSource: + """Return media.""" + return BrowseMediaSource( + domain=DOMAIN, + identifier=None, + media_class=MEDIA_CLASS_CHANNEL, + media_content_type=MEDIA_TYPE_MUSIC, + title=self.entry.title, + can_play=False, + can_expand=True, + children_media_class=MEDIA_CLASS_DIRECTORY, + children=[ + *await self._async_build_popular(item), + *await self._async_build_by_tag(item), + *await self._async_build_by_language(item), + *await self._async_build_by_country(item), + ], + ) + + @callback + @staticmethod + def _async_get_station_mime_type(station: Station) -> str | None: + """Determine mime type of a radio station.""" + mime_type = CODEC_TO_MIMETYPE.get(station.codec) + if not mime_type: + mime_type, _ = mimetypes.guess_type(station.url) + return mime_type + + @callback + def _async_build_stations(self, stations: list[Station]) -> list[BrowseMediaSource]: + """Build list of media sources from radio stations.""" + items: list[BrowseMediaSource] = [] + + for station in stations: + if station.codec == "UNKNOWN" or not ( + mime_type := self._async_get_station_mime_type(station) + ): + continue + + items.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=station.uuid, + media_class=MEDIA_CLASS_MUSIC, + media_content_type=mime_type, + title=station.name, + can_play=True, + can_expand=False, + thumbnail=station.favicon, + ) + ) + + return items + + async def _async_build_by_country( + self, item: MediaSourceItem + ) -> list[BrowseMediaSource]: + """Handle browsing radio stations by country.""" + category, _, country_code = (item.identifier or "").partition("/") + if country_code: + stations = await self.radios.stations( + filter_by=FilterBy.COUNTRY_CODE_EXACT, + filter_term=country_code, + hide_broken=True, + order=Order.NAME, + reverse=False, + ) + return self._async_build_stations(stations) + + # We show country in the root additionally, when there is no item + if not item.identifier or category == "country": + countries = await self.radios.countries(order=Order.NAME) + return [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"country/{country.code}", + media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=MEDIA_TYPE_MUSIC, + title=country.name, + can_play=False, + can_expand=True, + thumbnail=country.favicon, + ) + for country in countries + ] + + return [] + + async def _async_build_by_language( + self, item: MediaSourceItem + ) -> list[BrowseMediaSource]: + """Handle browsing radio stations by language.""" + category, _, language = (item.identifier or "").partition("/") + if category == "language" and language: + stations = await self.radios.stations( + filter_by=FilterBy.LANGUAGE_EXACT, + filter_term=language, + hide_broken=True, + order=Order.NAME, + reverse=False, + ) + return self._async_build_stations(stations) + + if category == "language": + languages = await self.radios.languages(order=Order.NAME, hide_broken=True) + return [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"language/{language.code}", + media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=MEDIA_TYPE_MUSIC, + title=language.name, + can_play=False, + can_expand=True, + thumbnail=language.favicon, + ) + for language in languages + ] + + if not item.identifier: + return [ + BrowseMediaSource( + domain=DOMAIN, + identifier="language", + media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=MEDIA_TYPE_MUSIC, + title="By Language", + can_play=False, + can_expand=True, + ) + ] + + return [] + + async def _async_build_popular( + self, item: MediaSourceItem + ) -> list[BrowseMediaSource]: + """Handle browsing popular radio stations.""" + if item.identifier == "popular": + stations = await self.radios.stations( + hide_broken=True, + limit=250, + order=Order.CLICK_COUNT, + reverse=True, + ) + return self._async_build_stations(stations) + + if not item.identifier: + return [ + BrowseMediaSource( + domain=DOMAIN, + identifier="popular", + media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=MEDIA_TYPE_MUSIC, + title="Popular", + can_play=False, + can_expand=True, + ) + ] + + return [] + + async def _async_build_by_tag( + self, item: MediaSourceItem + ) -> list[BrowseMediaSource]: + """Handle browsing radio stations by tags.""" + category, _, tag = (item.identifier or "").partition("/") + if category == "tag" and tag: + stations = await self.radios.stations( + filter_by=FilterBy.TAG_EXACT, + filter_term=tag, + hide_broken=True, + order=Order.NAME, + reverse=False, + ) + return self._async_build_stations(stations) + + if category == "tag": + tags = await self.radios.tags( + hide_broken=True, + limit=100, + order=Order.STATION_COUNT, + reverse=True, + ) + + # Now we have the top tags, reorder them by name + tags.sort(key=lambda tag: tag.name) + + return [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"tag/{tag.name}", + media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=MEDIA_TYPE_MUSIC, + title=tag.name.title(), + can_play=False, + can_expand=True, + ) + for tag in tags + ] + + if not item.identifier: + return [ + BrowseMediaSource( + domain=DOMAIN, + identifier="tag", + media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=MEDIA_TYPE_MUSIC, + title="By Category", + can_play=False, + can_expand=True, + ) + ] + + return [] diff --git a/homeassistant/components/radio_browser/strings.json b/homeassistant/components/radio_browser/strings.json new file mode 100644 index 00000000000..7bf9bc9ca66 --- /dev/null +++ b/homeassistant/components/radio_browser/strings.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "description": "Do you want to add Radio Browser to Home Assistant?" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radio_browser/translations/en.json b/homeassistant/components/radio_browser/translations/en.json new file mode 100644 index 00000000000..5f89dd9447c --- /dev/null +++ b/homeassistant/components/radio_browser/translations/en.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, + "step": { + "user": { + "description": "Do you want to add Radio Browser to Home Assistant?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index aa773be82f0..ddec2deb65e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -261,6 +261,7 @@ FLOWS = [ "pvoutput", "pvpc_hourly_pricing", "rachio", + "radio_browser", "rainforest_eagle", "rainmachine", "rdw", diff --git a/requirements_all.txt b/requirements_all.txt index bec28e688e7..cca10c6e92e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2077,6 +2077,9 @@ quantum-gateway==0.0.6 # homeassistant.components.rachio rachiopy==1.0.3 +# homeassistant.components.radio_browser +radios==0.1.0 + # homeassistant.components.radiotherm radiotherm==2.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5787a59ea57..602c6c3c2cb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1296,6 +1296,9 @@ pyzerproc==0.4.8 # homeassistant.components.rachio rachiopy==1.0.3 +# homeassistant.components.radio_browser +radios==0.1.0 + # homeassistant.components.rainmachine regenmaschine==2022.01.0 diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 9605fb9e71c..976e2b84c68 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -381,6 +381,23 @@ async def test_onboarding_core_sets_up_met(hass, hass_storage, hass_client): assert len(hass.states.async_entity_ids("weather")) == 1 +async def test_onboarding_core_sets_up_radio_browser(hass, hass_storage, hass_client): + """Test finishing the core step set up the radio browser.""" + mock_storage(hass_storage, {"done": [const.STEP_USER]}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.post("/api/onboarding/core_config") + + assert resp.status == 200 + + await hass.async_block_till_done() + assert len(hass.config_entries.async_entries("radio_browser")) == 1 + + async def test_onboarding_core_sets_up_rpi_power( hass, hass_storage, hass_client, aioclient_mock, rpi ): diff --git a/tests/components/radio_browser/__init__.py b/tests/components/radio_browser/__init__.py new file mode 100644 index 00000000000..708e07fefe6 --- /dev/null +++ b/tests/components/radio_browser/__init__.py @@ -0,0 +1 @@ +"""Tests for the Radio Browser integration.""" diff --git a/tests/components/radio_browser/conftest.py b/tests/components/radio_browser/conftest.py new file mode 100644 index 00000000000..5a5b888d944 --- /dev/null +++ b/tests/components/radio_browser/conftest.py @@ -0,0 +1,30 @@ +"""Fixtures for the Radio Browser integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.radio_browser.const import DOMAIN + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="My Radios", + domain=DOMAIN, + data={}, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.radio_browser.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup diff --git a/tests/components/radio_browser/test_config_flow.py b/tests/components/radio_browser/test_config_flow.py new file mode 100644 index 00000000000..8a5a3d9ccce --- /dev/null +++ b/tests/components/radio_browser/test_config_flow.py @@ -0,0 +1,65 @@ +"""Test the Radio Browser config flow.""" +from unittest.mock import AsyncMock + +from homeassistant.components.radio_browser.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + + +async def test_full_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("errors") is None + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result2.get("title") == "Radio Browser" + assert result2.get("data") == {} + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test we abort if the Radio Browser is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_ABORT + assert result.get("reason") == "single_instance_allowed" + + +async def test_onboarding_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test the onboarding configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "onboarding"} + ) + + assert result.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result.get("title") == "Radio Browser" + assert result.get("data") == {} + + assert len(mock_setup_entry.mock_calls) == 1