Add 'stations near me' to radio browser (#150907)

This commit is contained in:
karwosts
2025-09-15 11:47:16 -07:00
committed by GitHub
parent d51c0e3752
commit 1df1144eb9
3 changed files with 242 additions and 1 deletions

View File

@@ -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 []

View File

@@ -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

View File

@@ -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