feat(radiobrowser): add search, paging and favourite lookup
This commit is contained in:
1 parent
f96923b5b7
commit
638cc9eddb
8 files changed
+1018
-45
No files matched your search
+314
-3
@@ -8,7 +8,8 @@ import pytest
|
||||
|
||||
from beefy_player import config
|
||||
from beefy_player.app import BeefyPlayerApp
|
||||
from beefy_player.models import Song
|
||||
from beefy_player.models import Song, Station
|
||||
from beefy_player.providers.base import StationPage
|
||||
from beefy_player.widgets.art import ArtPane
|
||||
from beefy_player.widgets.help import HelpScreen
|
||||
from beefy_player.widgets.stage import StagePanel
|
||||
@@ -70,8 +71,10 @@ async def test_boot_navigation_filter_favourite_visualizer_help() -> None:
|
||||
await pilot.pause()
|
||||
assert app.current is not None
|
||||
assert app.current.id == "defcon"
|
||||
assert app.query == ""
|
||||
assert len(app.view) == len(app.stations)
|
||||
assert app.query == "defcon"
|
||||
assert [station.id for station in app.view] == ["defcon"]
|
||||
assert not search.has_class("visible")
|
||||
assert app.focused is None
|
||||
assert app.playing_state in ("stopped", "error")
|
||||
|
||||
app.action_favorite()
|
||||
@@ -100,6 +103,314 @@ async def test_boot_navigation_filter_favourite_visualizer_help() -> None:
|
||||
assert "DEF CON" in text.upper()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_escape_leaves_the_filter_sticky_and_a_second_escape_clears_it() -> None:
|
||||
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||
async with app.run_test(size=(110, 36)) as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
await pilot.press("slash")
|
||||
for char in "defcon":
|
||||
await pilot.press(char)
|
||||
await pilot.pause()
|
||||
search = app.query_one("#search")
|
||||
assert search.has_class("visible")
|
||||
assert app.query == "defcon"
|
||||
assert [station.id for station in app.view] == ["defcon"]
|
||||
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
assert app.query == "defcon"
|
||||
assert search.value == "defcon"
|
||||
assert not search.has_class("visible")
|
||||
assert app.focused is None
|
||||
assert [station.id for station in app.view] == ["defcon"]
|
||||
assert "defcon" in app.query_one(StationList).border_title
|
||||
|
||||
app.action_favorite()
|
||||
await pilot.pause()
|
||||
assert "defcon" in app.config.favorites
|
||||
assert app.query == "defcon"
|
||||
assert [station.id for station in app.view] == ["defcon"]
|
||||
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
assert app.query == ""
|
||||
assert search.value == ""
|
||||
assert len(app.view) == len(app.stations)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enter_keeps_the_filter_sticky_like_the_first_escape() -> None:
|
||||
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||
async with app.run_test(size=(110, 36)) as pilot:
|
||||
await pilot.pause()
|
||||
await pilot.press("slash")
|
||||
for char in "defcon":
|
||||
await pilot.press(char)
|
||||
await pilot.pause()
|
||||
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
search = app.query_one("#search")
|
||||
assert app.current is not None
|
||||
assert app.current.id == "defcon"
|
||||
assert app.query == "defcon"
|
||||
assert [station.id for station in app.view] == ["defcon"]
|
||||
assert not search.has_class("visible")
|
||||
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
assert app.query == ""
|
||||
assert len(app.view) == len(app.stations)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_directory_search_uses_the_server_not_the_loaded_list(monkeypatch) -> None:
|
||||
app = BeefyPlayerApp(
|
||||
provider="radiobrowser", autoplay=False, offline=True, audio=False
|
||||
)
|
||||
async with app.run_test(size=(110, 36)) as pilot:
|
||||
await pilot.pause()
|
||||
app.offline = False
|
||||
|
||||
found = Station(
|
||||
id="deep-jazz",
|
||||
title="Deep Jazz",
|
||||
description="France · french",
|
||||
genre="jazz",
|
||||
provider="radiobrowser",
|
||||
)
|
||||
queries: list[str] = []
|
||||
|
||||
async def fake_search(self, client, query):
|
||||
queries.append(query)
|
||||
return [found]
|
||||
|
||||
monkeypatch.setattr(type(app.provider), "search_stations", fake_search)
|
||||
|
||||
app.query = "jazz"
|
||||
app._rebuild_view()
|
||||
assert not any(station.id == "deep-jazz" for station in app.view)
|
||||
|
||||
worker = app._search_directory("jazz")
|
||||
await worker.wait()
|
||||
await pilot.pause()
|
||||
|
||||
assert queries == ["jazz"]
|
||||
assert [station.id for station in app.view] == ["deep-jazz"]
|
||||
assert app._find_station("deep-jazz") is found
|
||||
|
||||
app._tune(found)
|
||||
assert any(station.id == "deep-jazz" for station in app.stations)
|
||||
app._set_stations(
|
||||
[Station(id="other", title="Other", provider="radiobrowser")]
|
||||
)
|
||||
assert app.current.id == "deep-jazz"
|
||||
assert any(station.id == "deep-jazz" for station in app.stations)
|
||||
|
||||
app.query = ""
|
||||
app.search_results = None
|
||||
app._search_results_query = ""
|
||||
app._rebuild_view()
|
||||
assert len(app.view) == len(app.stations)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_refresh_replaces_a_fallback_station() -> None:
|
||||
app = BeefyPlayerApp(
|
||||
provider="radiobrowser", autoplay=False, offline=True, audio=False
|
||||
)
|
||||
async with app.run_test(size=(110, 36)) as pilot:
|
||||
await pilot.pause()
|
||||
assert app.current is not None
|
||||
assert app.current.id.startswith("fallback-")
|
||||
|
||||
app._set_stations(
|
||||
[Station(id="real", title="Real Station", provider="radiobrowser")]
|
||||
)
|
||||
assert app.current.id == "real"
|
||||
assert [station.id for station in app.stations] == ["real"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_favoriting_a_loaded_station_from_search_updates_it_in_place() -> None:
|
||||
app = BeefyPlayerApp(
|
||||
provider="radiobrowser", autoplay=False, offline=True, audio=False
|
||||
)
|
||||
async with app.run_test(size=(110, 36)) as pilot:
|
||||
await pilot.pause()
|
||||
existing = app.stations[0]
|
||||
search_copy = Station(
|
||||
id=existing.id, title=existing.title, provider="radiobrowser"
|
||||
)
|
||||
|
||||
app.query = "jazz"
|
||||
app._set_search_results("jazz", [search_copy])
|
||||
assert app.view[0] is existing
|
||||
|
||||
app.index = 0
|
||||
app.action_favorite()
|
||||
await pilot.pause()
|
||||
assert existing.favorite is True
|
||||
assert app.config.is_favorite(existing.id)
|
||||
|
||||
app._close_search(clear=True)
|
||||
await pilot.pause()
|
||||
assert app.view[0].id == existing.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_favoriting_a_directory_result_keeps_it_after_clearing_the_filter(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
app = BeefyPlayerApp(
|
||||
provider="radiobrowser", autoplay=False, offline=True, audio=False
|
||||
)
|
||||
async with app.run_test(size=(110, 36)) as pilot:
|
||||
await pilot.pause()
|
||||
app.offline = False
|
||||
|
||||
found = Station(
|
||||
id="deep-jazz", title="Deep Jazz", provider="radiobrowser"
|
||||
)
|
||||
|
||||
async def fake_search(self, client, query):
|
||||
return [found]
|
||||
|
||||
monkeypatch.setattr(type(app.provider), "search_stations", fake_search)
|
||||
|
||||
await pilot.press("slash")
|
||||
for char in "jazz":
|
||||
await pilot.press(char)
|
||||
await pilot.pause(0.4)
|
||||
assert [station.id for station in app.view] == ["deep-jazz"]
|
||||
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
app.action_favorite()
|
||||
await pilot.pause()
|
||||
assert found.favorite is True
|
||||
assert "deep-jazz" in app.config.favorites
|
||||
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
assert app.query == ""
|
||||
assert app.view[0].id == "deep-jazz"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_favorites_load_with_the_first_directory_page(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
app = BeefyPlayerApp(
|
||||
provider="radiobrowser", autoplay=False, offline=True, audio=False
|
||||
)
|
||||
async with app.run_test(size=(110, 36)) as pilot:
|
||||
await pilot.pause()
|
||||
app.offline = False
|
||||
|
||||
favourite = Station(
|
||||
id="12345678-1234-1234-1234-123456789abc",
|
||||
title="Favourite",
|
||||
provider="radiobrowser",
|
||||
)
|
||||
app.config.favorites = [favourite.id]
|
||||
page = StationPage(
|
||||
stations=[
|
||||
Station(id="page-0", title="Page Zero", provider="radiobrowser")
|
||||
],
|
||||
next_offset=100,
|
||||
)
|
||||
|
||||
async def fake_page(self, client, offset=0):
|
||||
return page
|
||||
|
||||
async def fake_by_ids(self, client, station_ids):
|
||||
assert station_ids == [favourite.id]
|
||||
return [favourite]
|
||||
|
||||
monkeypatch.setattr(type(app.provider), "fetch_station_page", fake_page)
|
||||
monkeypatch.setattr(
|
||||
type(app.provider), "fetch_stations_by_ids", fake_by_ids
|
||||
)
|
||||
|
||||
worker = app._refresh_stations()
|
||||
await worker.wait()
|
||||
await pilot.pause()
|
||||
|
||||
assert any(
|
||||
station.id == favourite.id and station.favorite
|
||||
for station in app.stations
|
||||
)
|
||||
assert app.view[0].id == favourite.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_paginated_catalogue_loads_the_next_page_near_the_end(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
app = BeefyPlayerApp(
|
||||
provider="radiobrowser", autoplay=False, offline=True, audio=False
|
||||
)
|
||||
async with app.run_test(size=(110, 36)) as pilot:
|
||||
await pilot.pause()
|
||||
app.offline = False
|
||||
|
||||
first = [
|
||||
Station(
|
||||
id=f"s{index}", title=f"Station {index}", provider="radiobrowser"
|
||||
)
|
||||
for index in range(100)
|
||||
]
|
||||
app._set_stations(first)
|
||||
app._reset_directory_paging(first, has_more=True)
|
||||
assert app._directory_offset == 100
|
||||
assert app._directory_exhausted is False
|
||||
|
||||
app.index = 0
|
||||
app._maybe_load_more_stations()
|
||||
assert app._loading_more is False
|
||||
|
||||
seen: list[int] = []
|
||||
|
||||
async def fake_page(self, client, offset=0):
|
||||
seen.append(offset)
|
||||
return StationPage(
|
||||
stations=[
|
||||
Station(id="extra", title="Extra", provider="radiobrowser")
|
||||
],
|
||||
next_offset=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(type(app.provider), "fetch_station_page", fake_page)
|
||||
app.index = 99
|
||||
worker = app._load_more_stations()
|
||||
await worker.wait()
|
||||
await pilot.pause()
|
||||
|
||||
assert seen == [100]
|
||||
assert any(station.id == "extra" for station in app.stations)
|
||||
assert app._directory_exhausted is True
|
||||
assert app.view[app.index].id == "s99"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_popularity_ordered_catalogue_keeps_its_order() -> None:
|
||||
app = BeefyPlayerApp(
|
||||
provider="radiobrowser", autoplay=False, offline=True, audio=False
|
||||
)
|
||||
async with app.run_test(size=(110, 36)) as pilot:
|
||||
await pilot.pause()
|
||||
app._set_stations(
|
||||
[
|
||||
Station(id="zulu", title="Zulu", provider="radiobrowser"),
|
||||
Station(id="alpha", title="Alpha", provider="radiobrowser"),
|
||||
]
|
||||
)
|
||||
assert [station.title for station in app.view] == ["Zulu", "Alpha"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stage_renders_history_and_listener_count() -> None:
|
||||
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||
|
||||
+115
-4
@@ -66,25 +66,136 @@ def test_hls_streams_survive_as_direct_urls() -> None:
|
||||
assert station.format_label == "aac · 64k"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_stations_queries_all_searchable_fields() -> None:
|
||||
entries = {
|
||||
"name": {**ENTRY, "stationuuid": "name", "name": "Jazz Name"},
|
||||
"tag": {**ENTRY, "stationuuid": "tag", "name": "Zed Jazz Tag"},
|
||||
"country": {**ENTRY, "stationuuid": "country", "name": "Country Jazz"},
|
||||
"language": {**ENTRY, "stationuuid": "language", "name": "Language Jazz"},
|
||||
}
|
||||
seen: list[dict[str, str]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
params = dict(request.url.params)
|
||||
seen.append(params)
|
||||
field = next(name for name in api._SEARCH_FIELDS if name in params)
|
||||
payload = [entries[field]]
|
||||
if field in ("name", "tag"):
|
||||
payload.append(ENTRY) # the same station may match twice
|
||||
return httpx.Response(200, json=payload)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
stations = await api.search_stations(client, "jazz")
|
||||
|
||||
assert {station.id for station in stations} == {
|
||||
ENTRY["stationuuid"],
|
||||
"name",
|
||||
"tag",
|
||||
"country",
|
||||
"language",
|
||||
}
|
||||
assert [params["limit"] for params in seen] == [
|
||||
str(api.SEARCH_LIMIT)
|
||||
] * len(api._SEARCH_FIELDS)
|
||||
for params in seen:
|
||||
assert params["hidebroken"] == "true"
|
||||
assert params["order"] == "clickcount"
|
||||
assert params["reverse"] == "true"
|
||||
assert {next(name for name in api._SEARCH_FIELDS if name in params) for params in seen} == set(
|
||||
api._SEARCH_FIELDS
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_stations_ignores_a_failed_field(monkeypatch) -> None:
|
||||
async def fake_fetch_json(client, url):
|
||||
if "name=jazz" in url:
|
||||
raise RuntimeError("name search is down")
|
||||
return [ENTRY]
|
||||
|
||||
monkeypatch.setattr(api, "fetch_json", fake_fetch_json)
|
||||
async with httpx.AsyncClient() as client:
|
||||
stations = await api.search_stations(client, "jazz")
|
||||
assert [station.id for station in stations] == [ENTRY["stationuuid"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_empty_search_does_not_hit_the_api() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise AssertionError("empty queries must not make a request")
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
assert await api.search_stations(client, " ") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_stations_caches_the_directory() -> None:
|
||||
payload = [ENTRY, {**ENTRY, "stationuuid": "x", "name": "Another"}]
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert "hidebroken=true" in str(request.url)
|
||||
assert "limit=300" in str(request.url)
|
||||
params = dict(request.url.params)
|
||||
assert params["hidebroken"] == "true"
|
||||
assert params["limit"] == str(api.PAGE_SIZE)
|
||||
assert params["offset"] == "0"
|
||||
return httpx.Response(200, json=payload)
|
||||
|
||||
provider = RadioBrowserProvider()
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
stations = await provider.fetch_stations(client)
|
||||
assert [station.title for station in stations] == ["Another", "France Info"]
|
||||
assert [station.title for station in stations] == ["France Info", "Another"]
|
||||
assert [station.title for station in provider.load_cached_stations()] == [
|
||||
"Another",
|
||||
"France Info",
|
||||
"Another",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_station_page_continues_in_catalogue_order() -> None:
|
||||
first = [
|
||||
{**ENTRY, "stationuuid": f"id-{index}", "name": f"Station {index}"}
|
||||
for index in range(api.PAGE_SIZE)
|
||||
]
|
||||
last = [{**ENTRY, "stationuuid": "last", "name": "Last Station"}]
|
||||
seen: list[dict[str, str]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
params = dict(request.url.params)
|
||||
seen.append(params)
|
||||
return httpx.Response(200, json=first if params["offset"] == "100" else last)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
page = await api.fetch_station_page(client, 100)
|
||||
end = await api.fetch_station_page(client, 200)
|
||||
|
||||
assert [station.id for station in page.stations] == [
|
||||
f"id-{index}" for index in range(api.PAGE_SIZE)
|
||||
]
|
||||
assert page.next_offset == 200
|
||||
assert end.next_offset is None
|
||||
assert [params["offset"] for params in seen] == ["100", "200"]
|
||||
assert [params["limit"] for params in seen] == [str(api.PAGE_SIZE)] * 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_stations_by_ids_uses_the_uuid_endpoint() -> None:
|
||||
seen: list[dict[str, str]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(dict(request.url.params))
|
||||
return httpx.Response(200, json=[ENTRY])
|
||||
|
||||
station_id = ENTRY["stationuuid"]
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
stations = await api.fetch_stations_by_ids(
|
||||
client, ["defcon", station_id, station_id]
|
||||
)
|
||||
|
||||
assert [station.id for station in stations] == [station_id]
|
||||
assert len(seen) == 1
|
||||
assert seen[0]["uuids"] == station_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_failed_fetch_does_not_kill_the_catalogue() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
|
||||
Reference in new issue
Block a user