Skip to content

Commit 1df1144

Browse files
authored
Add 'stations near me' to radio browser (#150907)
1 parent d51c0e3 commit 1df1144

File tree

3 files changed

+242
-1
lines changed

3 files changed

+242
-1
lines changed

homeassistant/components/radio_browser/media_source.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
Unresolvable,
1717
)
1818
from homeassistant.core import HomeAssistant, callback
19+
from homeassistant.util.location import vincenty
1920

2021
from . import RadioBrowserConfigEntry
2122
from .const import DOMAIN
@@ -88,6 +89,7 @@ async def async_browse_media(
8889
*await self._async_build_popular(radios, item),
8990
*await self._async_build_by_tag(radios, item),
9091
*await self._async_build_by_language(radios, item),
92+
*await self._async_build_local(radios, item),
9193
*await self._async_build_by_country(radios, item),
9294
],
9395
)
@@ -292,3 +294,63 @@ async def _async_build_by_tag(
292294
]
293295

294296
return []
297+
298+
def _filter_local_stations(
299+
self, stations: list[Station], latitude: float, longitude: float
300+
) -> list[Station]:
301+
return [
302+
station
303+
for station in stations
304+
if station.latitude is not None
305+
and station.longitude is not None
306+
and (
307+
(
308+
dist := vincenty(
309+
(latitude, longitude),
310+
(station.latitude, station.longitude),
311+
False,
312+
)
313+
)
314+
is not None
315+
)
316+
and dist < 100
317+
]
318+
319+
async def _async_build_local(
320+
self, radios: RadioBrowser, item: MediaSourceItem
321+
) -> list[BrowseMediaSource]:
322+
"""Handle browsing local radio stations."""
323+
324+
if item.identifier == "local":
325+
country = self.hass.config.country
326+
stations = await radios.stations(
327+
filter_by=FilterBy.COUNTRY_CODE_EXACT,
328+
filter_term=country,
329+
hide_broken=True,
330+
order=Order.NAME,
331+
reverse=False,
332+
)
333+
334+
local_stations = await self.hass.async_add_executor_job(
335+
self._filter_local_stations,
336+
stations,
337+
self.hass.config.latitude,
338+
self.hass.config.longitude,
339+
)
340+
341+
return self._async_build_stations(radios, local_stations)
342+
343+
if not item.identifier:
344+
return [
345+
BrowseMediaSource(
346+
domain=DOMAIN,
347+
identifier="local",
348+
media_class=MediaClass.DIRECTORY,
349+
media_content_type=MediaType.MUSIC,
350+
title="Local stations",
351+
can_play=False,
352+
can_expand=True,
353+
)
354+
]
355+
356+
return []

tests/components/radio_browser/conftest.py

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
from __future__ import annotations
44

55
from collections.abc import Generator
6-
from unittest.mock import AsyncMock, patch
6+
from unittest.mock import AsyncMock, MagicMock, patch
77

88
import pytest
99

1010
from homeassistant.components.radio_browser.const import DOMAIN
11+
from homeassistant.core import HomeAssistant
1112

1213
from tests.common import MockConfigEntry
1314

@@ -29,3 +30,108 @@ def mock_setup_entry() -> Generator[AsyncMock]:
2930
"homeassistant.components.radio_browser.async_setup_entry", return_value=True
3031
) as mock_setup:
3132
yield mock_setup
33+
34+
35+
@pytest.fixture
36+
async def init_integration(
37+
hass: HomeAssistant,
38+
mock_config_entry: MockConfigEntry,
39+
) -> MockConfigEntry:
40+
"""Set up the Radio Browser integration for testing."""
41+
mock_config_entry.add_to_hass(hass)
42+
43+
await hass.config_entries.async_setup(mock_config_entry.entry_id)
44+
await hass.async_block_till_done()
45+
46+
return mock_config_entry
47+
48+
49+
@pytest.fixture
50+
def mock_countries():
51+
"Generate mock countries for the countries method of the radios object."
52+
53+
class MockCountry:
54+
"""Country Object for Radios."""
55+
56+
def __init__(self, code, name) -> None:
57+
"""Initialize a mock country."""
58+
self.code = code
59+
self.name = name
60+
self.favicon = "fake.png"
61+
62+
return [MockCountry("US", "United States")]
63+
64+
65+
@pytest.fixture
66+
def mock_stations():
67+
"Generate mock stations for the stations method of the radios object."
68+
69+
class MockStation:
70+
"""Station object for Radios."""
71+
72+
def __init__(self, country_code, latitude, longitude, name, uuid) -> None:
73+
"""Initialize a mock station."""
74+
self.country_code = country_code
75+
self.latitude = latitude
76+
self.longitude = longitude
77+
self.uuid = uuid
78+
self.name = name
79+
self.codec = "MP3"
80+
self.favicon = "fake.png"
81+
82+
return [
83+
MockStation(
84+
country_code="US",
85+
latitude=45.52000,
86+
longitude=-122.63961,
87+
name="Near Station 1",
88+
uuid="1",
89+
),
90+
MockStation(
91+
country_code="US",
92+
latitude=None,
93+
longitude=None,
94+
name="Unknown location station",
95+
uuid="2",
96+
),
97+
MockStation(
98+
country_code="US",
99+
latitude=47.57071,
100+
longitude=-122.21148,
101+
name="Moderate Far Station",
102+
uuid="3",
103+
),
104+
MockStation(
105+
country_code="US",
106+
latitude=45.73943,
107+
longitude=-121.51859,
108+
name="Near Station 2",
109+
uuid="4",
110+
),
111+
MockStation(
112+
country_code="US",
113+
latitude=44.99026,
114+
longitude=-69.27804,
115+
name="Really Far Station",
116+
uuid="5",
117+
),
118+
]
119+
120+
121+
@pytest.fixture
122+
def mock_radios(mock_countries, mock_stations):
123+
"""Provide a radios mock object."""
124+
radios = MagicMock()
125+
radios.countries = AsyncMock(return_value=mock_countries)
126+
radios.stations = AsyncMock(return_value=mock_stations)
127+
return radios
128+
129+
130+
@pytest.fixture
131+
def patch_radios(monkeypatch: pytest.MonkeyPatch, mock_radios):
132+
"""Replace the radios object in the source with the mock object (with mock stations and countries)."""
133+
134+
def _patch(source):
135+
monkeypatch.setattr(type(source), "radios", mock_radios)
136+
137+
return _patch
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Tests for radio_browser media_source."""
2+
3+
from unittest.mock import AsyncMock
4+
5+
import pytest
6+
from radios import FilterBy, Order
7+
8+
from homeassistant.components import media_source
9+
from homeassistant.components.radio_browser.media_source import async_get_media_source
10+
from homeassistant.core import HomeAssistant
11+
from homeassistant.setup import async_setup_component
12+
13+
DOMAIN = "radio_browser"
14+
15+
16+
@pytest.fixture(autouse=True)
17+
async def setup_media_source(hass: HomeAssistant) -> None:
18+
"""Set up media source."""
19+
assert await async_setup_component(hass, "media_source", {})
20+
21+
22+
async def test_browsing_local(
23+
hass: HomeAssistant, init_integration: AsyncMock, patch_radios
24+
) -> None:
25+
"""Test browsing local stations."""
26+
27+
hass.config.latitude = 45.58539
28+
hass.config.longitude = -122.40320
29+
hass.config.country = "US"
30+
31+
source = await async_get_media_source(hass)
32+
patch_radios(source)
33+
34+
item = await media_source.async_browse_media(
35+
hass, f"{media_source.URI_SCHEME}{DOMAIN}"
36+
)
37+
38+
assert item is not None
39+
assert item.title == "My Radios"
40+
assert item.children is not None
41+
assert len(item.children) == 5
42+
assert item.can_play is False
43+
assert item.can_expand is True
44+
45+
assert item.children[3].title == "Local stations"
46+
47+
item_child = await media_source.async_browse_media(
48+
hass, item.children[3].media_content_id
49+
)
50+
51+
source.radios.stations.assert_awaited_with(
52+
filter_by=FilterBy.COUNTRY_CODE_EXACT,
53+
filter_term=hass.config.country,
54+
hide_broken=True,
55+
order=Order.NAME,
56+
reverse=False,
57+
)
58+
59+
assert item_child is not None
60+
assert item_child.title == "My Radios"
61+
assert len(item_child.children) == 2
62+
assert item_child.children[0].title == "Near Station 1"
63+
assert item_child.children[1].title == "Near Station 2"
64+
65+
# Test browsing a different category to hit the path where async_build_local
66+
# returns []
67+
other_browse = await media_source.async_browse_media(
68+
hass, f"{media_source.URI_SCHEME}{DOMAIN}/nonexistent"
69+
)
70+
71+
assert other_browse is not None
72+
assert other_browse.title == "My Radios"
73+
assert len(other_browse.children) == 0

0 commit comments

Comments
 (0)