From 1df1144eb96853d721985f1f75961f09fe8e1872 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Mon, 15 Sep 2025 11:47:16 -0700 Subject: [PATCH] Add 'stations near me' to radio browser (#150907) --- .../components/radio_browser/media_source.py | 62 ++++++++++ tests/components/radio_browser/conftest.py | 108 +++++++++++++++++- .../radio_browser/test_media_source.py | 73 ++++++++++++ 3 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 tests/components/radio_browser/test_media_source.py diff --git a/homeassistant/components/radio_browser/media_source.py b/homeassistant/components/radio_browser/media_source.py index 2cc243323a1..e62fe0325cc 100644 --- a/homeassistant/components/radio_browser/media_source.py +++ b/homeassistant/components/radio_browser/media_source.py @@ -16,6 +16,7 @@ from homeassistant.components.media_source import ( Unresolvable, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.util.location import vincenty from . import RadioBrowserConfigEntry from .const import DOMAIN @@ -88,6 +89,7 @@ class RadioMediaSource(MediaSource): *await self._async_build_popular(radios, item), *await self._async_build_by_tag(radios, item), *await self._async_build_by_language(radios, item), + *await self._async_build_local(radios, item), *await self._async_build_by_country(radios, item), ], ) @@ -292,3 +294,63 @@ class RadioMediaSource(MediaSource): ] return [] + + def _filter_local_stations( + self, stations: list[Station], latitude: float, longitude: float + ) -> list[Station]: + return [ + station + for station in stations + if station.latitude is not None + and station.longitude is not None + and ( + ( + dist := vincenty( + (latitude, longitude), + (station.latitude, station.longitude), + False, + ) + ) + is not None + ) + and dist < 100 + ] + + async def _async_build_local( + self, radios: RadioBrowser, item: MediaSourceItem + ) -> list[BrowseMediaSource]: + """Handle browsing local radio stations.""" + + if item.identifier == "local": + country = self.hass.config.country + stations = await radios.stations( + filter_by=FilterBy.COUNTRY_CODE_EXACT, + filter_term=country, + hide_broken=True, + order=Order.NAME, + reverse=False, + ) + + local_stations = await self.hass.async_add_executor_job( + self._filter_local_stations, + stations, + self.hass.config.latitude, + self.hass.config.longitude, + ) + + return self._async_build_stations(radios, local_stations) + + if not item.identifier: + return [ + BrowseMediaSource( + domain=DOMAIN, + identifier="local", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaType.MUSIC, + title="Local stations", + can_play=False, + can_expand=True, + ) + ] + + return [] diff --git a/tests/components/radio_browser/conftest.py b/tests/components/radio_browser/conftest.py index fc666b32c53..24bd93e48a7 100644 --- a/tests/components/radio_browser/conftest.py +++ b/tests/components/radio_browser/conftest.py @@ -3,11 +3,12 @@ from __future__ import annotations from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.components.radio_browser.const import DOMAIN +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -29,3 +30,108 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.radio_browser.async_setup_entry", return_value=True ) as mock_setup: yield mock_setup + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> MockConfigEntry: + """Set up the Radio Browser integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry + + +@pytest.fixture +def mock_countries(): + "Generate mock countries for the countries method of the radios object." + + class MockCountry: + """Country Object for Radios.""" + + def __init__(self, code, name) -> None: + """Initialize a mock country.""" + self.code = code + self.name = name + self.favicon = "fake.png" + + return [MockCountry("US", "United States")] + + +@pytest.fixture +def mock_stations(): + "Generate mock stations for the stations method of the radios object." + + class MockStation: + """Station object for Radios.""" + + def __init__(self, country_code, latitude, longitude, name, uuid) -> None: + """Initialize a mock station.""" + self.country_code = country_code + self.latitude = latitude + self.longitude = longitude + self.uuid = uuid + self.name = name + self.codec = "MP3" + self.favicon = "fake.png" + + return [ + MockStation( + country_code="US", + latitude=45.52000, + longitude=-122.63961, + name="Near Station 1", + uuid="1", + ), + MockStation( + country_code="US", + latitude=None, + longitude=None, + name="Unknown location station", + uuid="2", + ), + MockStation( + country_code="US", + latitude=47.57071, + longitude=-122.21148, + name="Moderate Far Station", + uuid="3", + ), + MockStation( + country_code="US", + latitude=45.73943, + longitude=-121.51859, + name="Near Station 2", + uuid="4", + ), + MockStation( + country_code="US", + latitude=44.99026, + longitude=-69.27804, + name="Really Far Station", + uuid="5", + ), + ] + + +@pytest.fixture +def mock_radios(mock_countries, mock_stations): + """Provide a radios mock object.""" + radios = MagicMock() + radios.countries = AsyncMock(return_value=mock_countries) + radios.stations = AsyncMock(return_value=mock_stations) + return radios + + +@pytest.fixture +def patch_radios(monkeypatch: pytest.MonkeyPatch, mock_radios): + """Replace the radios object in the source with the mock object (with mock stations and countries).""" + + def _patch(source): + monkeypatch.setattr(type(source), "radios", mock_radios) + + return _patch diff --git a/tests/components/radio_browser/test_media_source.py b/tests/components/radio_browser/test_media_source.py new file mode 100644 index 00000000000..a9d08c1e438 --- /dev/null +++ b/tests/components/radio_browser/test_media_source.py @@ -0,0 +1,73 @@ +"""Tests for radio_browser media_source.""" + +from unittest.mock import AsyncMock + +import pytest +from radios import FilterBy, Order + +from homeassistant.components import media_source +from homeassistant.components.radio_browser.media_source import async_get_media_source +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +DOMAIN = "radio_browser" + + +@pytest.fixture(autouse=True) +async def setup_media_source(hass: HomeAssistant) -> None: + """Set up media source.""" + assert await async_setup_component(hass, "media_source", {}) + + +async def test_browsing_local( + hass: HomeAssistant, init_integration: AsyncMock, patch_radios +) -> None: + """Test browsing local stations.""" + + hass.config.latitude = 45.58539 + hass.config.longitude = -122.40320 + hass.config.country = "US" + + source = await async_get_media_source(hass) + patch_radios(source) + + item = await media_source.async_browse_media( + hass, f"{media_source.URI_SCHEME}{DOMAIN}" + ) + + assert item is not None + assert item.title == "My Radios" + assert item.children is not None + assert len(item.children) == 5 + assert item.can_play is False + assert item.can_expand is True + + assert item.children[3].title == "Local stations" + + item_child = await media_source.async_browse_media( + hass, item.children[3].media_content_id + ) + + source.radios.stations.assert_awaited_with( + filter_by=FilterBy.COUNTRY_CODE_EXACT, + filter_term=hass.config.country, + hide_broken=True, + order=Order.NAME, + reverse=False, + ) + + assert item_child is not None + assert item_child.title == "My Radios" + assert len(item_child.children) == 2 + assert item_child.children[0].title == "Near Station 1" + assert item_child.children[1].title == "Near Station 2" + + # Test browsing a different category to hit the path where async_build_local + # returns [] + other_browse = await media_source.async_browse_media( + hass, f"{media_source.URI_SCHEME}{DOMAIN}/nonexistent" + ) + + assert other_browse is not None + assert other_browse.title == "My Radios" + assert len(other_browse.children) == 0