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
+354
-11
@@ -44,6 +44,10 @@ from .widgets.status import StatusBar
|
||||
|
||||
VOLUME_STEP = 5
|
||||
|
||||
#: How close the cursor may get to the end of a paginated list before the next
|
||||
#: directory page is fetched.
|
||||
LOAD_MORE_MARGIN = 20
|
||||
|
||||
# A theme made only of terminal defaults / ANSI palette entries. Textual's
|
||||
# built-in themes paint an RGB background and map ANSI names to their own RGB
|
||||
# palette, which breaks transparency and ignores the user's colours.
|
||||
@@ -146,6 +150,20 @@ class BeefyPlayerApp(App[None]):
|
||||
self.schedule_loaded = False
|
||||
self.index = 0
|
||||
self.query = ""
|
||||
#: Server-side directory results for ``query``; ``None`` means the
|
||||
#: view is filtered locally from :attr:`stations`.
|
||||
self.search_results: list[Station] | None = None
|
||||
self._search_results_query = ""
|
||||
#: A server-side search is in flight; the list shows a searching hint.
|
||||
self.search_pending = False
|
||||
#: Stations reached through a search or a later directory page. They
|
||||
#: are kept when a refresh only fetches the first page again.
|
||||
self._discovered_station_ids: set[str] = set()
|
||||
#: Directory paging: next offset, whether there are more pages, and
|
||||
#: whether a page request is already in flight.
|
||||
self._directory_offset = 0
|
||||
self._directory_exhausted = True
|
||||
self._loading_more = False
|
||||
self.current: Station | None = None
|
||||
self.playing_state = "stopped"
|
||||
self.volume = self.config.volume
|
||||
@@ -209,9 +227,11 @@ class BeefyPlayerApp(App[None]):
|
||||
self._apply_catalogue_chrome()
|
||||
|
||||
stations = self.provider.load_cached_stations()
|
||||
cached = bool(stations)
|
||||
if not stations:
|
||||
stations = self.provider.fallback_stations()
|
||||
self._set_stations(stations)
|
||||
self._reset_directory_paging(stations, has_more=cached)
|
||||
self._select_initial_station()
|
||||
|
||||
if self.current is not None:
|
||||
@@ -259,10 +279,35 @@ class BeefyPlayerApp(App[None]):
|
||||
self.config.visualizer = self.visualizer
|
||||
self.config.save()
|
||||
|
||||
def _find_station(self, station_id: str) -> Station | None:
|
||||
"""Find a station by id: on air, in the catalogue, or in the search."""
|
||||
if self.current is not None and self.current.id == station_id:
|
||||
return self.current
|
||||
for station in self.stations:
|
||||
if station.id == station_id:
|
||||
return station
|
||||
for station in self.search_results or ():
|
||||
if station.id == station_id:
|
||||
return station
|
||||
return None
|
||||
|
||||
def _set_stations(self, stations: list[Station]) -> None:
|
||||
if not stations:
|
||||
return
|
||||
stations = list(stations)
|
||||
known = {station.id for station in stations}
|
||||
for station in self.stations:
|
||||
if (
|
||||
station.id in self._discovered_station_ids
|
||||
and station.id not in known
|
||||
):
|
||||
# Keep stations found through search or a later page when a
|
||||
# refresh only knows the first page.
|
||||
stations.append(station)
|
||||
known.add(station.id)
|
||||
existing = {station.id: station for station in self.stations}
|
||||
if self.current is not None:
|
||||
existing[self.current.id] = self.current
|
||||
for station in stations:
|
||||
previous = existing.get(station.id)
|
||||
if previous is not None:
|
||||
@@ -284,6 +329,33 @@ class BeefyPlayerApp(App[None]):
|
||||
self._update_artwork()
|
||||
self.query_one(HeaderBar).refresh()
|
||||
|
||||
def _reset_directory_paging(
|
||||
self, stations: list[Station], *, has_more: bool = True
|
||||
) -> None:
|
||||
self._loading_more = False
|
||||
if not self.provider.paginated or not has_more:
|
||||
self._directory_offset = 0
|
||||
self._directory_exhausted = True
|
||||
return
|
||||
# The cached list is in catalogue order, so continue after it.
|
||||
self._directory_offset = len(stations)
|
||||
self._directory_exhausted = False
|
||||
|
||||
def _append_stations(self, stations: list[Station]) -> int:
|
||||
"""Add a page to the catalogue, preserving existing station state."""
|
||||
known = {station.id for station in self.stations}
|
||||
added = 0
|
||||
for station in stations:
|
||||
if station.id in known:
|
||||
continue
|
||||
station.favorite = self.config.is_favorite(station.id)
|
||||
if station.now_playing is None and station.last_playing:
|
||||
station.now_playing = parse_last_playing(station.last_playing)
|
||||
self.stations.append(station)
|
||||
known.add(station.id)
|
||||
added += 1
|
||||
return added
|
||||
|
||||
def _select_initial_station(self) -> None:
|
||||
if not self.view:
|
||||
return
|
||||
@@ -307,7 +379,8 @@ class BeefyPlayerApp(App[None]):
|
||||
break
|
||||
|
||||
def _rebuild_view(self, keep_id: str | None = None) -> None:
|
||||
terms = self.query.casefold().split()
|
||||
query = self.query.strip()
|
||||
terms = query.casefold().split()
|
||||
|
||||
def matches(station: Station) -> bool:
|
||||
if not terms:
|
||||
@@ -321,8 +394,27 @@ class BeefyPlayerApp(App[None]):
|
||||
if keep_id is None and self.view and 0 <= self.index < len(self.view):
|
||||
keep_id = self.view[self.index].id
|
||||
|
||||
view = [station for station in self.stations if matches(station)]
|
||||
view.sort(key=lambda station: (0 if station.favorite else 1, station.title.casefold()))
|
||||
if (
|
||||
terms
|
||||
and self.search_results is not None
|
||||
and self._search_results_query == query
|
||||
):
|
||||
# The directory matched fields that are not all part of a Station's
|
||||
# local search text; trust its answer instead of filtering again.
|
||||
view = list(self.search_results)
|
||||
else:
|
||||
view = [station for station in self.stations if matches(station)]
|
||||
if self.provider.preserve_order:
|
||||
# Stable sort: keep the catalogue's popularity order within the
|
||||
# favourites and everything else.
|
||||
view.sort(key=lambda station: 0 if station.favorite else 1)
|
||||
else:
|
||||
view.sort(
|
||||
key=lambda station: (
|
||||
0 if station.favorite else 1,
|
||||
station.title.casefold(),
|
||||
)
|
||||
)
|
||||
self.view = view
|
||||
|
||||
if keep_id is not None:
|
||||
@@ -338,6 +430,39 @@ class BeefyPlayerApp(App[None]):
|
||||
if self.current is None and view:
|
||||
self.current = view[self.index]
|
||||
|
||||
def _set_search_results(self, query: str, stations: list[Station]) -> None:
|
||||
"""Show a server-side directory search in place of the loaded list."""
|
||||
known = {station.id: station for station in self.stations}
|
||||
if self.current is not None:
|
||||
known[self.current.id] = self.current
|
||||
results: list[Station] = []
|
||||
for station in stations:
|
||||
previous = known.get(station.id)
|
||||
if previous is not None:
|
||||
# Reuse the catalogue's object so favourite/history changes
|
||||
# made in the search results show up in the regular list too.
|
||||
station = previous
|
||||
else:
|
||||
station.favorite = self.config.is_favorite(station.id)
|
||||
if station.now_playing is None and station.last_playing:
|
||||
station.now_playing = parse_last_playing(station.last_playing)
|
||||
results.append(station)
|
||||
|
||||
self.search_results = results
|
||||
self._search_results_query = query
|
||||
self.search_pending = False
|
||||
self._rebuild_view(
|
||||
keep_id=self.current.id if self.current is not None else None
|
||||
)
|
||||
if self.current is None or not any(
|
||||
station.id == self.current.id for station in self.view
|
||||
):
|
||||
self.index = 0
|
||||
with contextlib.suppress(NoMatches):
|
||||
pane = self.query_one(StationList)
|
||||
pane.set_filter(self.query)
|
||||
pane.refresh()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# the catalogue's chrome
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -362,6 +487,9 @@ class BeefyPlayerApp(App[None]):
|
||||
def _reset_filter(self) -> None:
|
||||
"""Forget the station filter and put the search box away."""
|
||||
self.query = ""
|
||||
self.search_results = None
|
||||
self._search_results_query = ""
|
||||
self.search_pending = False
|
||||
search = self.query_one(SearchInput)
|
||||
search.value = ""
|
||||
search.remove_class("visible")
|
||||
@@ -385,6 +513,12 @@ class BeefyPlayerApp(App[None]):
|
||||
def _tune(self, station: Station) -> None:
|
||||
self.current = station
|
||||
self.start_station_id = station.id
|
||||
if not any(existing.id == station.id for existing in self.stations):
|
||||
# A directory search can reach a station outside the loaded pages;
|
||||
# keep it in the catalogue so clearing the filter still shows it.
|
||||
self.stations.append(station)
|
||||
# Remember it so a refresh that only fetches the first page keeps it.
|
||||
self._discovered_station_ids.add(station.id)
|
||||
self.playing_state = "loading"
|
||||
self._set_notice(f"tuning in to {station.title}…", 5)
|
||||
self._save_config()
|
||||
@@ -405,7 +539,7 @@ class BeefyPlayerApp(App[None]):
|
||||
|
||||
@work(exclusive=True, group="stream", exit_on_error=False)
|
||||
async def _resolve_and_play(self, station_id: str) -> None:
|
||||
station = next((s for s in self.stations if s.id == station_id), None)
|
||||
station = self._find_station(station_id)
|
||||
if station is None or self.current is None or self.current.id != station_id:
|
||||
return
|
||||
|
||||
@@ -440,7 +574,7 @@ class BeefyPlayerApp(App[None]):
|
||||
@work(exclusive=True, group="history", exit_on_error=False)
|
||||
async def _fetch_history(self, station_id: str) -> None:
|
||||
self._last_history_fetch = time.monotonic()
|
||||
station = next((s for s in self.stations if s.id == station_id), None)
|
||||
station = self._find_station(station_id)
|
||||
if station is None or self._http is None:
|
||||
return
|
||||
try:
|
||||
@@ -559,7 +693,7 @@ class BeefyPlayerApp(App[None]):
|
||||
return
|
||||
|
||||
self._bandcamp_urls[f"{station_id}|{song_key}"] = url
|
||||
station = next((s for s in self.stations if s.id == station_id), None)
|
||||
station = self._find_station(station_id)
|
||||
if station is None or self.current is None or self.current.id != station_id:
|
||||
return
|
||||
song = station.now_playing
|
||||
@@ -793,27 +927,179 @@ class BeefyPlayerApp(App[None]):
|
||||
# workers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@work(exclusive=True, group="stations-page", exit_on_error=False)
|
||||
async def _load_more_stations(self) -> None:
|
||||
try:
|
||||
if (
|
||||
not self.provider.paginated
|
||||
or self._directory_exhausted
|
||||
or self.offline
|
||||
or self._http is None
|
||||
):
|
||||
return
|
||||
page = await self.provider.fetch_station_page(
|
||||
self._http, self._directory_offset
|
||||
)
|
||||
except Exception:
|
||||
self._set_notice("could not load more stations", 4)
|
||||
return
|
||||
finally:
|
||||
self._loading_more = False
|
||||
|
||||
self._directory_offset = page.next_offset or 0
|
||||
self._directory_exhausted = page.next_offset is None
|
||||
if not self._append_stations(page.stations):
|
||||
return
|
||||
|
||||
keep_id = (
|
||||
self.view[self.index].id
|
||||
if self.view and 0 <= self.index < len(self.view)
|
||||
else None
|
||||
)
|
||||
self._rebuild_view(keep_id=keep_id)
|
||||
with contextlib.suppress(NoMatches):
|
||||
pane = self.query_one(StationList)
|
||||
pane.set_filter(self.query)
|
||||
pane.refresh()
|
||||
|
||||
def _maybe_load_more_stations(self) -> None:
|
||||
"""Queue the next directory page when the cursor nears the end."""
|
||||
if (
|
||||
not self.provider.paginated
|
||||
or self._loading_more
|
||||
or self._directory_exhausted
|
||||
or self.offline
|
||||
or not self.view
|
||||
or self.query.strip()
|
||||
):
|
||||
return
|
||||
if self.index < len(self.view) - LOAD_MORE_MARGIN:
|
||||
return
|
||||
self._loading_more = True
|
||||
self._set_notice("loading more stations…", 3)
|
||||
self._load_more_stations()
|
||||
|
||||
@work(exclusive=True, group="search", exit_on_error=False)
|
||||
async def _search_directory(self, query: str) -> None:
|
||||
"""Debounced server-side search, for catalogues that support it."""
|
||||
text = query.strip()
|
||||
if (
|
||||
not text
|
||||
or not self.provider.server_search
|
||||
or self.offline
|
||||
or self._http is None
|
||||
):
|
||||
self.search_pending = False
|
||||
return
|
||||
# A new keystroke starts a new exclusive worker and cancels this sleep.
|
||||
await asyncio.sleep(0.25)
|
||||
if text != self.query.strip():
|
||||
return
|
||||
try:
|
||||
stations = await self.provider.search_stations(self._http, text)
|
||||
except Exception:
|
||||
if text == self.query.strip():
|
||||
self.search_pending = False
|
||||
self._set_notice(
|
||||
"directory search failed — filtering the loaded stations", 4
|
||||
)
|
||||
self._refresh_pane()
|
||||
return
|
||||
if text != self.query.strip():
|
||||
return
|
||||
self._set_search_results(text, stations)
|
||||
|
||||
async def _load_favorite_stations(self) -> None:
|
||||
"""Fetch favourites that are not on the currently loaded page."""
|
||||
if self.offline or self._http is None or not self.provider.paginated:
|
||||
return
|
||||
known = {station.id for station in self.stations}
|
||||
missing = [
|
||||
station_id
|
||||
for station_id in self.config.favorites
|
||||
if station_id not in known
|
||||
]
|
||||
if not missing:
|
||||
return
|
||||
try:
|
||||
stations = await self.provider.fetch_stations_by_ids(
|
||||
self._http, missing
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
added = 0
|
||||
for station in stations:
|
||||
if any(existing.id == station.id for existing in self.stations):
|
||||
continue
|
||||
if not self.config.is_favorite(station.id):
|
||||
continue
|
||||
previous = next(
|
||||
(
|
||||
searched
|
||||
for searched in (self.search_results or ())
|
||||
if searched.id == station.id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if previous is not None:
|
||||
# A search result and a favourite lookup can race; keep one
|
||||
# object so favourite/history changes are shared.
|
||||
station = previous
|
||||
station.favorite = True
|
||||
self.stations.append(station)
|
||||
self._discovered_station_ids.add(station.id)
|
||||
added += 1
|
||||
if not added:
|
||||
return
|
||||
|
||||
keep_id = (
|
||||
self.view[self.index].id
|
||||
if self.view and 0 <= self.index < len(self.view)
|
||||
else None
|
||||
)
|
||||
self._rebuild_view(keep_id=keep_id)
|
||||
with contextlib.suppress(NoMatches):
|
||||
pane = self.query_one(StationList)
|
||||
pane.set_filter(self.query)
|
||||
pane.refresh()
|
||||
|
||||
@work(exclusive=True, group="stations", exit_on_error=False)
|
||||
async def _refresh_stations(self) -> None:
|
||||
if self.offline or self._http is None:
|
||||
return
|
||||
try:
|
||||
stations = await self.provider.fetch_stations(self._http)
|
||||
if self.provider.paginated:
|
||||
page = await self.provider.fetch_station_page(self._http, 0)
|
||||
stations = page.stations
|
||||
next_offset = page.next_offset
|
||||
else:
|
||||
stations = await self.provider.fetch_stations(self._http)
|
||||
next_offset = None
|
||||
if not stations:
|
||||
raise RuntimeError("the catalogue returned no stations")
|
||||
except Exception:
|
||||
if not self.stations:
|
||||
self._set_stations(self.provider.fallback_stations())
|
||||
self._set_notice("offline — using the built-in station list", 8)
|
||||
else:
|
||||
self._set_notice("station refresh failed — using the cached list", 6)
|
||||
# The full directory may be unreachable while a favourite lookup
|
||||
# still works; show those rather than hiding them until a restart.
|
||||
await self._load_favorite_stations()
|
||||
return
|
||||
before = self.current.id if self.current is not None else None
|
||||
self._set_stations(stations)
|
||||
if before is not None:
|
||||
self._select_station_id(before)
|
||||
self._directory_offset = next_offset or 0
|
||||
self._directory_exhausted = next_offset is None
|
||||
self._loading_more = False
|
||||
self._set_notice(
|
||||
"1 station" if len(stations) == 1 else f"{len(stations)} stations", 3
|
||||
)
|
||||
self.query_one(StationList).refresh()
|
||||
await self._load_favorite_stations()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# actions
|
||||
@@ -830,6 +1116,7 @@ class BeefyPlayerApp(App[None]):
|
||||
return
|
||||
self.index = min(len(self.view) - 1, self.index + 1)
|
||||
self.query_one(StationList).refresh()
|
||||
self._maybe_load_more_stations()
|
||||
|
||||
def _page(self, delta: int) -> None:
|
||||
if not self.view:
|
||||
@@ -837,6 +1124,7 @@ class BeefyPlayerApp(App[None]):
|
||||
page = max(1, self.query_one(StationList).size.height - 1)
|
||||
self.index = max(0, min(len(self.view) - 1, self.index + delta * page))
|
||||
self.query_one(StationList).refresh()
|
||||
self._maybe_load_more_stations()
|
||||
|
||||
def action_page_up(self) -> None:
|
||||
self._page(-1)
|
||||
@@ -855,6 +1143,7 @@ class BeefyPlayerApp(App[None]):
|
||||
return
|
||||
self.index = len(self.view) - 1
|
||||
self.query_one(StationList).refresh()
|
||||
self._maybe_load_more_stations()
|
||||
|
||||
def action_tune(self) -> None:
|
||||
if not self.view:
|
||||
@@ -914,6 +1203,21 @@ class BeefyPlayerApp(App[None]):
|
||||
return
|
||||
added = self.config.toggle_favorite(station.id)
|
||||
station.favorite = added
|
||||
known = False
|
||||
for existing in self.stations:
|
||||
if existing.id == station.id:
|
||||
# A search copy and the catalogue copy can briefly coexist;
|
||||
# keep both in step so clearing the filter cannot resurrect
|
||||
# the old favourite state.
|
||||
existing.favorite = added
|
||||
known = True
|
||||
break
|
||||
if added:
|
||||
# A favourite reached through a directory search must join the
|
||||
# catalogue, or clearing the filter would hide it again.
|
||||
self._discovered_station_ids.add(station.id)
|
||||
if not known:
|
||||
self.stations.append(station)
|
||||
self._save_config()
|
||||
self._rebuild_view(keep_id=station.id)
|
||||
self._set_notice(("★ added " if added else "☆ removed ") + station.title, 3)
|
||||
@@ -932,9 +1236,12 @@ class BeefyPlayerApp(App[None]):
|
||||
search.cursor_position = len(search.value)
|
||||
|
||||
def action_escape(self) -> None:
|
||||
if self.query:
|
||||
search = self.query_one(SearchInput)
|
||||
if self.query and not search.has_class("visible"):
|
||||
# The filter is already sticky: a second escape clears it.
|
||||
self._close_search(clear=True)
|
||||
else:
|
||||
# Put the search box away but keep the filter on the station list.
|
||||
self._close_search(clear=False)
|
||||
|
||||
def _close_search(self, clear: bool = False) -> None:
|
||||
@@ -942,6 +1249,9 @@ class BeefyPlayerApp(App[None]):
|
||||
search.remove_class("visible")
|
||||
if clear:
|
||||
self.query = ""
|
||||
self.search_results = None
|
||||
self._search_results_query = ""
|
||||
self.search_pending = False
|
||||
search.value = ""
|
||||
search.disabled = True
|
||||
self.set_focus(None)
|
||||
@@ -949,6 +1259,7 @@ class BeefyPlayerApp(App[None]):
|
||||
self.query_one(StationList).set_filter(self.query)
|
||||
self.query_one(StationList).refresh()
|
||||
self.query_one(StagePanel).refresh_stage()
|
||||
self._maybe_load_more_stations()
|
||||
|
||||
def action_visualizer(self) -> None:
|
||||
modes = list(config.VISUALIZERS)
|
||||
@@ -979,6 +1290,9 @@ class BeefyPlayerApp(App[None]):
|
||||
self._last_history_fetch = 0.0
|
||||
self._bandcamp_key = ""
|
||||
self._bandcamp_urls.clear()
|
||||
with contextlib.suppress(Exception):
|
||||
self.workers.cancel_group(self, "stations-page")
|
||||
self._loading_more = False
|
||||
self._set_notice("refreshing…", 2)
|
||||
if self.offline:
|
||||
self._set_notice("offline mode — nothing to refresh", 3)
|
||||
@@ -1009,7 +1323,16 @@ class BeefyPlayerApp(App[None]):
|
||||
self.query_one(ArtPane).resume()
|
||||
|
||||
def _cancel_workers(self) -> None:
|
||||
for group in ("stream", "history", "stations", "schedule", "bandcamp", "artwork"):
|
||||
for group in (
|
||||
"stream",
|
||||
"history",
|
||||
"search",
|
||||
"stations",
|
||||
"stations-page",
|
||||
"schedule",
|
||||
"bandcamp",
|
||||
"artwork",
|
||||
):
|
||||
with contextlib.suppress(Exception):
|
||||
self.workers.cancel_group(self, group)
|
||||
|
||||
@@ -1039,6 +1362,10 @@ class BeefyPlayerApp(App[None]):
|
||||
self.stations = []
|
||||
self.view = []
|
||||
self.index = 0
|
||||
self.search_results = None
|
||||
self._search_results_query = ""
|
||||
self.search_pending = False
|
||||
self._discovered_station_ids.clear()
|
||||
self.current = None
|
||||
self.schedule = []
|
||||
self.schedule_loaded = False
|
||||
@@ -1055,8 +1382,12 @@ class BeefyPlayerApp(App[None]):
|
||||
self._apply_catalogue_chrome()
|
||||
with contextlib.suppress(NoMatches):
|
||||
self._reset_filter()
|
||||
stations = provider.load_cached_stations() or provider.fallback_stations()
|
||||
stations = provider.load_cached_stations()
|
||||
cached = bool(stations)
|
||||
if not stations:
|
||||
stations = provider.fallback_stations()
|
||||
self._set_stations(stations)
|
||||
self._reset_directory_paging(stations, has_more=cached)
|
||||
self._select_initial_station()
|
||||
self._update_media()
|
||||
self._update_artwork()
|
||||
@@ -1096,15 +1427,27 @@ class BeefyPlayerApp(App[None]):
|
||||
if event.input.id != "search":
|
||||
return
|
||||
self.query = event.value
|
||||
self.search_results = None
|
||||
self._search_results_query = ""
|
||||
self.search_pending = (
|
||||
self.provider.server_search
|
||||
and not self.offline
|
||||
and bool(event.value.strip())
|
||||
)
|
||||
self._rebuild_view()
|
||||
self.query_one(StationList).set_filter(self.query)
|
||||
self.query_one(StationList).refresh()
|
||||
if self.provider.server_search:
|
||||
self._search_directory(event.value)
|
||||
self._maybe_load_more_stations()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
if event.input.id != "search":
|
||||
return
|
||||
station = self.view[self.index] if self.view else None
|
||||
self._close_search(clear=True)
|
||||
# Enter tunes, and leaves the filter in place exactly like the first
|
||||
# escape does; a second escape returns to the regular station list.
|
||||
self._close_search(clear=False)
|
||||
if station is not None:
|
||||
self._tune(station)
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import asyncio
|
||||
import re
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
@@ -29,6 +30,7 @@ from ..models import Broadcast, Song, Station
|
||||
__all__ = [
|
||||
"PLAYLIST_TTL",
|
||||
"RadioProvider",
|
||||
"StationPage",
|
||||
"fetch_json",
|
||||
"is_direct_stream",
|
||||
"is_playlist",
|
||||
@@ -62,6 +64,15 @@ def reset_playlist_cache() -> None:
|
||||
_PLAYLIST_CACHE = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class StationPage:
|
||||
"""One page of a large catalogue, in the catalogue's own order."""
|
||||
|
||||
stations: list[Station]
|
||||
#: Offset to request next, or ``None`` when the catalogue is exhausted.
|
||||
next_offset: int | None = None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# the interface
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -114,6 +125,20 @@ class RadioProvider(ABC):
|
||||
#: stations that would rather not be scraped).
|
||||
bandcamp: bool = True
|
||||
|
||||
#: The catalogue can search its whole directory on the server. When true,
|
||||
#: the app calls :meth:`search_stations` as the filter changes instead of
|
||||
#: only filtering the (possibly small) list from :meth:`fetch_stations`.
|
||||
server_search: bool = False
|
||||
|
||||
#: The catalogue is larger than one page. When true, the app fetches the
|
||||
#: first page with :meth:`fetch_station_page` and asks for the next one as
|
||||
#: the station list nears its end.
|
||||
paginated: bool = False
|
||||
|
||||
#: Keep the catalogue's own ordering (popularity, schedule, ...) instead of
|
||||
#: sorting the station list alphabetically.
|
||||
preserve_order: bool = False
|
||||
|
||||
# -- catalogue ------------------------------------------------------- #
|
||||
|
||||
@abstractmethod
|
||||
@@ -128,6 +153,37 @@ class RadioProvider(ABC):
|
||||
def fallback_stations(self) -> list[Station]:
|
||||
"""A bundled list, so a fresh offline start still works."""
|
||||
|
||||
async def search_stations(
|
||||
self, client: httpx.AsyncClient, query: str
|
||||
) -> list[Station]:
|
||||
"""Search the catalogue for *query*.
|
||||
|
||||
Only called when :attr:`server_search` is true. Providers that search
|
||||
locally leave this alone; the app filters the loaded list itself.
|
||||
"""
|
||||
return []
|
||||
|
||||
async def fetch_station_page(
|
||||
self, client: httpx.AsyncClient, offset: int = 0
|
||||
) -> StationPage:
|
||||
"""One page of a paginated catalogue.
|
||||
|
||||
Only called when :attr:`paginated` is true. ``offset`` is the value
|
||||
from the previous page's :attr:`StationPage.next_offset`.
|
||||
"""
|
||||
return StationPage(stations=[], next_offset=None)
|
||||
|
||||
async def fetch_stations_by_ids(
|
||||
self, client: httpx.AsyncClient, station_ids: list[str]
|
||||
) -> list[Station]:
|
||||
"""Fetch specific stations by id.
|
||||
|
||||
Used for favourites that are not on the first page of a paginated
|
||||
catalogue. Providers that return their whole catalogue at once can
|
||||
leave this alone.
|
||||
"""
|
||||
return []
|
||||
|
||||
# -- metadata -------------------------------------------------------- #
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Radio Browser: a directory of the community's stations rather than a station.
|
||||
|
||||
Three hundred of the most-clicked working relays, keyless and public-domain.
|
||||
There is no now-playing API, so this catalogue offers breadth, not history: pick
|
||||
a station and the stream speaks for itself.
|
||||
The most-clicked working relays are loaded a page at a time, and the rest of
|
||||
the 60,000-station directory is searched on the server as you type. There is no
|
||||
now-playing API, so this catalogue offers breadth, not history: pick a station
|
||||
and the stream speaks for itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -11,7 +12,7 @@ import httpx
|
||||
|
||||
from ... import config
|
||||
from ...models import Song, Station
|
||||
from ..base import RadioProvider
|
||||
from ..base import RadioProvider, StationPage
|
||||
from . import api, fallback
|
||||
from . import hero as hero_art
|
||||
|
||||
@@ -23,8 +24,8 @@ class RadioBrowserProvider(RadioProvider):
|
||||
name = "Radio Browser"
|
||||
homepage = "radio-browser.info"
|
||||
brand = "RADIO BROWSER"
|
||||
tagline = "community directory · 50,000 stations"
|
||||
station_source = "radio-browser.info (top 300)"
|
||||
tagline = "community directory · live search"
|
||||
station_source = "radio-browser.info (paged + search)"
|
||||
history_source = ""
|
||||
|
||||
hero = hero_art.HERO
|
||||
@@ -33,6 +34,14 @@ class RadioBrowserProvider(RadioProvider):
|
||||
# A directory has no show schedule and no Bandcamp worth scraping.
|
||||
bandcamp = False
|
||||
|
||||
# The full directory is much larger than the list fetched at startup; the
|
||||
# app asks the API when the filter changes.
|
||||
server_search = True
|
||||
|
||||
# The directory is loaded a page at a time, in click-count order.
|
||||
paginated = True
|
||||
preserve_order = True
|
||||
|
||||
@property
|
||||
def user_agent(self) -> str:
|
||||
return f"{config.USER_AGENT}; +https://www.radio-browser.info"
|
||||
@@ -40,6 +49,21 @@ class RadioBrowserProvider(RadioProvider):
|
||||
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
|
||||
return await api.fetch_stations(client)
|
||||
|
||||
async def fetch_station_page(
|
||||
self, client: httpx.AsyncClient, offset: int = 0
|
||||
) -> StationPage:
|
||||
return await api.fetch_station_page(client, offset)
|
||||
|
||||
async def fetch_stations_by_ids(
|
||||
self, client: httpx.AsyncClient, station_ids: list[str]
|
||||
) -> list[Station]:
|
||||
return await api.fetch_stations_by_ids(client, station_ids)
|
||||
|
||||
async def search_stations(
|
||||
self, client: httpx.AsyncClient, query: str
|
||||
) -> list[Station]:
|
||||
return await api.search_stations(client, query)
|
||||
|
||||
def load_cached_stations(self) -> list[Station]:
|
||||
return api.load_cached_stations()
|
||||
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
``https://de1.api.radio-browser.info/json/stations/search`` is keyless, CORS-open
|
||||
and public-domain data. It knows nothing about what a station is playing —
|
||||
there is no per-station history — so this catalogue is a *directory*: three
|
||||
hundred of the most-clicked working stations, ready to tune into. Whatever the
|
||||
relay puts in its stream is what you hear; the app has no metadata to show.
|
||||
there is no per-station history — so this catalogue is a *directory*: the most
|
||||
clicked working stations, a page at a time. Whatever the relay puts in its
|
||||
stream is what you hear; the app has no metadata to show.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
@@ -16,18 +18,34 @@ import httpx
|
||||
|
||||
from ... import config
|
||||
from ...models import Station, to_int
|
||||
from ..base import fetch_json
|
||||
from ..base import StationPage, fetch_json
|
||||
|
||||
API_URL = "https://de1.api.radio-browser.info/json/stations/search"
|
||||
BY_UUID_URL = "https://de1.api.radio-browser.info/json/stations/byuuid"
|
||||
|
||||
#: Three hundred is a comfortable directory: enough to browse, quick to fetch.
|
||||
STATION_LIMIT = 300
|
||||
#: Stations per directory page. The app loads the first page at startup and
|
||||
#: the next ones as the station list nears its end.
|
||||
PAGE_SIZE = 100
|
||||
|
||||
#: Results per field for a directory search. The four fields are merged.
|
||||
SEARCH_LIMIT = 100
|
||||
|
||||
#: The search endpoint has no full-text parameter, so a free-text query is
|
||||
#: sent once per field and the answers are merged.
|
||||
_SEARCH_FIELDS = ("name", "tag", "country", "language")
|
||||
|
||||
#: Radio Browser ids are UUIDs; anything else is another catalogue's favourite.
|
||||
_UUID_PATTERN = re.compile(
|
||||
r"^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$"
|
||||
)
|
||||
|
||||
#: How many UUIDs to ask for in one ``byuuid`` request.
|
||||
LOOKUP_BATCH = 50
|
||||
|
||||
_PARAMS = {
|
||||
"hidebroken": "true",
|
||||
"order": "clickcount",
|
||||
"reverse": "true",
|
||||
"limit": str(STATION_LIMIT),
|
||||
}
|
||||
|
||||
_CODECS = {"mp3": "mp3", "aac": "aac", "aac+": "aacp", "ogg": "ogg", "opus": "opus"}
|
||||
@@ -81,7 +99,7 @@ def station_from_entry(entry: dict[str, Any]) -> Station | None:
|
||||
)
|
||||
|
||||
|
||||
def parse_stations(payload: Any) -> list[Station]:
|
||||
def parse_stations(payload: Any, *, sort: bool = True) -> list[Station]:
|
||||
if not isinstance(payload, list):
|
||||
return []
|
||||
stations: list[Station] = []
|
||||
@@ -92,22 +110,129 @@ def parse_stations(payload: Any) -> list[Station]:
|
||||
continue
|
||||
seen.add(station.id)
|
||||
stations.append(station)
|
||||
stations.sort(key=lambda station: station.title.casefold())
|
||||
if sort:
|
||||
stations.sort(key=lambda station: station.title.casefold())
|
||||
return stations
|
||||
|
||||
|
||||
def _page_url(offset: int) -> str:
|
||||
params = {**_PARAMS, "limit": str(PAGE_SIZE), "offset": str(offset)}
|
||||
return f"{API_URL}?{urlencode(params)}"
|
||||
|
||||
|
||||
async def fetch_station_page(
|
||||
client: httpx.AsyncClient, offset: int = 0
|
||||
) -> StationPage:
|
||||
"""One page of the directory, most-clicked first."""
|
||||
try:
|
||||
payload = await fetch_json(client, _page_url(offset))
|
||||
except RuntimeError as exc:
|
||||
raise RuntimeError(f"could not fetch the Radio Browser directory: {exc}") from None
|
||||
|
||||
stations = parse_stations(payload, sort=False)
|
||||
rows = len(payload) if isinstance(payload, list) else 0
|
||||
next_offset = offset + PAGE_SIZE if rows >= PAGE_SIZE else None
|
||||
if offset == 0 and stations and isinstance(payload, list):
|
||||
config.write_json(config.channels_cache_path("radiobrowser"), payload)
|
||||
return StationPage(stations=stations, next_offset=next_offset)
|
||||
|
||||
|
||||
async def fetch_stations(client: httpx.AsyncClient) -> list[Station]:
|
||||
url = f"{API_URL}?{urlencode(_PARAMS)}"
|
||||
try:
|
||||
payload = await fetch_json(client, url)
|
||||
except RuntimeError as exc:
|
||||
raise RuntimeError(f"could not fetch the Radio Browser directory: {exc}") from None
|
||||
stations = parse_stations(payload)
|
||||
if not stations:
|
||||
page = await fetch_station_page(client, 0)
|
||||
if not page.stations:
|
||||
raise RuntimeError("Radio Browser returned no stations")
|
||||
config.write_json(config.channels_cache_path("radiobrowser"), payload)
|
||||
return stations
|
||||
return page.stations
|
||||
|
||||
|
||||
def load_cached_stations() -> list[Station]:
|
||||
return parse_stations(config.read_json(config.channels_cache_path("radiobrowser")))
|
||||
return parse_stations(
|
||||
config.read_json(config.channels_cache_path("radiobrowser")), sort=False
|
||||
)
|
||||
|
||||
|
||||
async def _search_field(
|
||||
client: httpx.AsyncClient, query: str, field: str
|
||||
) -> list[Station]:
|
||||
params = {**_PARAMS, "limit": str(SEARCH_LIMIT), field: query}
|
||||
payload = await fetch_json(client, f"{API_URL}?{urlencode(params)}")
|
||||
return parse_stations(payload, sort=False)
|
||||
|
||||
|
||||
async def search_stations(client: httpx.AsyncClient, query: str) -> list[Station]:
|
||||
"""Search the directory by name, tag, country and language.
|
||||
|
||||
The API has no single full-text search, so the query is sent once per field
|
||||
and the results are merged. A station that matches a genre rarely has that
|
||||
genre in its name, and vice versa. Fields that fail are ignored as long as
|
||||
one of them still answers; only a complete failure is raised.
|
||||
"""
|
||||
text = query.strip()
|
||||
if not text:
|
||||
return []
|
||||
|
||||
batches = await asyncio.gather(
|
||||
*(_search_field(client, text, field) for field in _SEARCH_FIELDS),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
stations: list[Station] = []
|
||||
seen: set[str] = set()
|
||||
failures = 0
|
||||
for batch in batches:
|
||||
if isinstance(batch, BaseException):
|
||||
failures += 1
|
||||
continue
|
||||
for station in batch:
|
||||
if station.id in seen:
|
||||
continue
|
||||
seen.add(station.id)
|
||||
stations.append(station)
|
||||
|
||||
if failures == len(batches):
|
||||
raise RuntimeError("could not search the Radio Browser directory")
|
||||
|
||||
return stations
|
||||
|
||||
|
||||
async def _fetch_by_uuids(
|
||||
client: httpx.AsyncClient, station_ids: list[str]
|
||||
) -> list[Station]:
|
||||
params = urlencode({"uuids": ",".join(station_ids)}, safe=",")
|
||||
payload = await fetch_json(client, f"{BY_UUID_URL}?{params}")
|
||||
return parse_stations(payload, sort=False)
|
||||
|
||||
|
||||
async def fetch_stations_by_ids(
|
||||
client: httpx.AsyncClient, station_ids: list[str]
|
||||
) -> list[Station]:
|
||||
"""Look up specific stations, for favourites outside the first page."""
|
||||
ids = list(
|
||||
dict.fromkeys(
|
||||
station_id.strip()
|
||||
for station_id in station_ids
|
||||
if _UUID_PATTERN.match(station_id.strip())
|
||||
)
|
||||
)
|
||||
if not ids:
|
||||
return []
|
||||
|
||||
batches = [
|
||||
ids[index : index + LOOKUP_BATCH]
|
||||
for index in range(0, len(ids), LOOKUP_BATCH)
|
||||
]
|
||||
results = await asyncio.gather(
|
||||
*(_fetch_by_uuids(client, batch) for batch in batches),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
stations: list[Station] = []
|
||||
seen: set[str] = set()
|
||||
for batch in results:
|
||||
if isinstance(batch, BaseException):
|
||||
continue
|
||||
for station in batch:
|
||||
if station.id in seen:
|
||||
continue
|
||||
seen.add(station.id)
|
||||
stations.append(station)
|
||||
return stations
|
||||
@@ -35,7 +35,7 @@ HELP_ROWS: tuple[tuple[str, str], ...] = (
|
||||
("m", "mute / unmute"),
|
||||
("f", "favourite — favourites float to the top"),
|
||||
("/", "filter stations by title, genre or description"),
|
||||
("esc", "clear the filter / close overlays"),
|
||||
("esc", "close the search box; esc again clears the filter"),
|
||||
(
|
||||
"v",
|
||||
"cycle visualiser: mirror → bars → wave → waterfall → radial → braille",
|
||||
|
||||
@@ -88,7 +88,10 @@ class StationList(Widget):
|
||||
view = app.view
|
||||
|
||||
if not view:
|
||||
message = "no stations match" if app.query else "loading stations…"
|
||||
if getattr(app, "search_pending", False):
|
||||
message = "searching the directory…"
|
||||
else:
|
||||
message = "no stations match" if app.query else "loading stations…"
|
||||
return frame.wrap(
|
||||
[Text(truncate(message, inner_width), style="dim italic")],
|
||||
width,
|
||||
|
||||
+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