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
|
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
|
# 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
|
# 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.
|
# palette, which breaks transparency and ignores the user's colours.
|
||||||
@@ -146,6 +150,20 @@ class BeefyPlayerApp(App[None]):
|
|||||||
self.schedule_loaded = False
|
self.schedule_loaded = False
|
||||||
self.index = 0
|
self.index = 0
|
||||||
self.query = ""
|
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.current: Station | None = None
|
||||||
self.playing_state = "stopped"
|
self.playing_state = "stopped"
|
||||||
self.volume = self.config.volume
|
self.volume = self.config.volume
|
||||||
@@ -209,9 +227,11 @@ class BeefyPlayerApp(App[None]):
|
|||||||
self._apply_catalogue_chrome()
|
self._apply_catalogue_chrome()
|
||||||
|
|
||||||
stations = self.provider.load_cached_stations()
|
stations = self.provider.load_cached_stations()
|
||||||
|
cached = bool(stations)
|
||||||
if not stations:
|
if not stations:
|
||||||
stations = self.provider.fallback_stations()
|
stations = self.provider.fallback_stations()
|
||||||
self._set_stations(stations)
|
self._set_stations(stations)
|
||||||
|
self._reset_directory_paging(stations, has_more=cached)
|
||||||
self._select_initial_station()
|
self._select_initial_station()
|
||||||
|
|
||||||
if self.current is not None:
|
if self.current is not None:
|
||||||
@@ -259,10 +279,35 @@ class BeefyPlayerApp(App[None]):
|
|||||||
self.config.visualizer = self.visualizer
|
self.config.visualizer = self.visualizer
|
||||||
self.config.save()
|
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:
|
def _set_stations(self, stations: list[Station]) -> None:
|
||||||
if not stations:
|
if not stations:
|
||||||
return
|
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}
|
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:
|
for station in stations:
|
||||||
previous = existing.get(station.id)
|
previous = existing.get(station.id)
|
||||||
if previous is not None:
|
if previous is not None:
|
||||||
@@ -284,6 +329,33 @@ class BeefyPlayerApp(App[None]):
|
|||||||
self._update_artwork()
|
self._update_artwork()
|
||||||
self.query_one(HeaderBar).refresh()
|
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:
|
def _select_initial_station(self) -> None:
|
||||||
if not self.view:
|
if not self.view:
|
||||||
return
|
return
|
||||||
@@ -307,7 +379,8 @@ class BeefyPlayerApp(App[None]):
|
|||||||
break
|
break
|
||||||
|
|
||||||
def _rebuild_view(self, keep_id: str | None = None) -> None:
|
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:
|
def matches(station: Station) -> bool:
|
||||||
if not terms:
|
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):
|
if keep_id is None and self.view and 0 <= self.index < len(self.view):
|
||||||
keep_id = self.view[self.index].id
|
keep_id = self.view[self.index].id
|
||||||
|
|
||||||
view = [station for station in self.stations if matches(station)]
|
if (
|
||||||
view.sort(key=lambda station: (0 if station.favorite else 1, station.title.casefold()))
|
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
|
self.view = view
|
||||||
|
|
||||||
if keep_id is not None:
|
if keep_id is not None:
|
||||||
@@ -338,6 +430,39 @@ class BeefyPlayerApp(App[None]):
|
|||||||
if self.current is None and view:
|
if self.current is None and view:
|
||||||
self.current = view[self.index]
|
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
|
# the catalogue's chrome
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -362,6 +487,9 @@ class BeefyPlayerApp(App[None]):
|
|||||||
def _reset_filter(self) -> None:
|
def _reset_filter(self) -> None:
|
||||||
"""Forget the station filter and put the search box away."""
|
"""Forget the station filter and put the search box away."""
|
||||||
self.query = ""
|
self.query = ""
|
||||||
|
self.search_results = None
|
||||||
|
self._search_results_query = ""
|
||||||
|
self.search_pending = False
|
||||||
search = self.query_one(SearchInput)
|
search = self.query_one(SearchInput)
|
||||||
search.value = ""
|
search.value = ""
|
||||||
search.remove_class("visible")
|
search.remove_class("visible")
|
||||||
@@ -385,6 +513,12 @@ class BeefyPlayerApp(App[None]):
|
|||||||
def _tune(self, station: Station) -> None:
|
def _tune(self, station: Station) -> None:
|
||||||
self.current = station
|
self.current = station
|
||||||
self.start_station_id = station.id
|
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.playing_state = "loading"
|
||||||
self._set_notice(f"tuning in to {station.title}…", 5)
|
self._set_notice(f"tuning in to {station.title}…", 5)
|
||||||
self._save_config()
|
self._save_config()
|
||||||
@@ -405,7 +539,7 @@ class BeefyPlayerApp(App[None]):
|
|||||||
|
|
||||||
@work(exclusive=True, group="stream", exit_on_error=False)
|
@work(exclusive=True, group="stream", exit_on_error=False)
|
||||||
async def _resolve_and_play(self, station_id: str) -> None:
|
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:
|
if station is None or self.current is None or self.current.id != station_id:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -440,7 +574,7 @@ class BeefyPlayerApp(App[None]):
|
|||||||
@work(exclusive=True, group="history", exit_on_error=False)
|
@work(exclusive=True, group="history", exit_on_error=False)
|
||||||
async def _fetch_history(self, station_id: str) -> None:
|
async def _fetch_history(self, station_id: str) -> None:
|
||||||
self._last_history_fetch = time.monotonic()
|
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:
|
if station is None or self._http is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@@ -559,7 +693,7 @@ class BeefyPlayerApp(App[None]):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self._bandcamp_urls[f"{station_id}|{song_key}"] = url
|
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:
|
if station is None or self.current is None or self.current.id != station_id:
|
||||||
return
|
return
|
||||||
song = station.now_playing
|
song = station.now_playing
|
||||||
@@ -793,27 +927,179 @@ class BeefyPlayerApp(App[None]):
|
|||||||
# workers
|
# 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)
|
@work(exclusive=True, group="stations", exit_on_error=False)
|
||||||
async def _refresh_stations(self) -> None:
|
async def _refresh_stations(self) -> None:
|
||||||
if self.offline or self._http is None:
|
if self.offline or self._http is None:
|
||||||
return
|
return
|
||||||
try:
|
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:
|
except Exception:
|
||||||
if not self.stations:
|
if not self.stations:
|
||||||
self._set_stations(self.provider.fallback_stations())
|
self._set_stations(self.provider.fallback_stations())
|
||||||
self._set_notice("offline — using the built-in station list", 8)
|
self._set_notice("offline — using the built-in station list", 8)
|
||||||
else:
|
else:
|
||||||
self._set_notice("station refresh failed — using the cached list", 6)
|
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
|
return
|
||||||
before = self.current.id if self.current is not None else None
|
before = self.current.id if self.current is not None else None
|
||||||
self._set_stations(stations)
|
self._set_stations(stations)
|
||||||
if before is not None:
|
if before is not None:
|
||||||
self._select_station_id(before)
|
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(
|
self._set_notice(
|
||||||
"1 station" if len(stations) == 1 else f"{len(stations)} stations", 3
|
"1 station" if len(stations) == 1 else f"{len(stations)} stations", 3
|
||||||
)
|
)
|
||||||
self.query_one(StationList).refresh()
|
self.query_one(StationList).refresh()
|
||||||
|
await self._load_favorite_stations()
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# actions
|
# actions
|
||||||
@@ -830,6 +1116,7 @@ class BeefyPlayerApp(App[None]):
|
|||||||
return
|
return
|
||||||
self.index = min(len(self.view) - 1, self.index + 1)
|
self.index = min(len(self.view) - 1, self.index + 1)
|
||||||
self.query_one(StationList).refresh()
|
self.query_one(StationList).refresh()
|
||||||
|
self._maybe_load_more_stations()
|
||||||
|
|
||||||
def _page(self, delta: int) -> None:
|
def _page(self, delta: int) -> None:
|
||||||
if not self.view:
|
if not self.view:
|
||||||
@@ -837,6 +1124,7 @@ class BeefyPlayerApp(App[None]):
|
|||||||
page = max(1, self.query_one(StationList).size.height - 1)
|
page = max(1, self.query_one(StationList).size.height - 1)
|
||||||
self.index = max(0, min(len(self.view) - 1, self.index + delta * page))
|
self.index = max(0, min(len(self.view) - 1, self.index + delta * page))
|
||||||
self.query_one(StationList).refresh()
|
self.query_one(StationList).refresh()
|
||||||
|
self._maybe_load_more_stations()
|
||||||
|
|
||||||
def action_page_up(self) -> None:
|
def action_page_up(self) -> None:
|
||||||
self._page(-1)
|
self._page(-1)
|
||||||
@@ -855,6 +1143,7 @@ class BeefyPlayerApp(App[None]):
|
|||||||
return
|
return
|
||||||
self.index = len(self.view) - 1
|
self.index = len(self.view) - 1
|
||||||
self.query_one(StationList).refresh()
|
self.query_one(StationList).refresh()
|
||||||
|
self._maybe_load_more_stations()
|
||||||
|
|
||||||
def action_tune(self) -> None:
|
def action_tune(self) -> None:
|
||||||
if not self.view:
|
if not self.view:
|
||||||
@@ -914,6 +1203,21 @@ class BeefyPlayerApp(App[None]):
|
|||||||
return
|
return
|
||||||
added = self.config.toggle_favorite(station.id)
|
added = self.config.toggle_favorite(station.id)
|
||||||
station.favorite = added
|
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._save_config()
|
||||||
self._rebuild_view(keep_id=station.id)
|
self._rebuild_view(keep_id=station.id)
|
||||||
self._set_notice(("★ added " if added else "☆ removed ") + station.title, 3)
|
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)
|
search.cursor_position = len(search.value)
|
||||||
|
|
||||||
def action_escape(self) -> None:
|
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)
|
self._close_search(clear=True)
|
||||||
else:
|
else:
|
||||||
|
# Put the search box away but keep the filter on the station list.
|
||||||
self._close_search(clear=False)
|
self._close_search(clear=False)
|
||||||
|
|
||||||
def _close_search(self, clear: bool = False) -> None:
|
def _close_search(self, clear: bool = False) -> None:
|
||||||
@@ -942,6 +1249,9 @@ class BeefyPlayerApp(App[None]):
|
|||||||
search.remove_class("visible")
|
search.remove_class("visible")
|
||||||
if clear:
|
if clear:
|
||||||
self.query = ""
|
self.query = ""
|
||||||
|
self.search_results = None
|
||||||
|
self._search_results_query = ""
|
||||||
|
self.search_pending = False
|
||||||
search.value = ""
|
search.value = ""
|
||||||
search.disabled = True
|
search.disabled = True
|
||||||
self.set_focus(None)
|
self.set_focus(None)
|
||||||
@@ -949,6 +1259,7 @@ class BeefyPlayerApp(App[None]):
|
|||||||
self.query_one(StationList).set_filter(self.query)
|
self.query_one(StationList).set_filter(self.query)
|
||||||
self.query_one(StationList).refresh()
|
self.query_one(StationList).refresh()
|
||||||
self.query_one(StagePanel).refresh_stage()
|
self.query_one(StagePanel).refresh_stage()
|
||||||
|
self._maybe_load_more_stations()
|
||||||
|
|
||||||
def action_visualizer(self) -> None:
|
def action_visualizer(self) -> None:
|
||||||
modes = list(config.VISUALIZERS)
|
modes = list(config.VISUALIZERS)
|
||||||
@@ -979,6 +1290,9 @@ class BeefyPlayerApp(App[None]):
|
|||||||
self._last_history_fetch = 0.0
|
self._last_history_fetch = 0.0
|
||||||
self._bandcamp_key = ""
|
self._bandcamp_key = ""
|
||||||
self._bandcamp_urls.clear()
|
self._bandcamp_urls.clear()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
self.workers.cancel_group(self, "stations-page")
|
||||||
|
self._loading_more = False
|
||||||
self._set_notice("refreshing…", 2)
|
self._set_notice("refreshing…", 2)
|
||||||
if self.offline:
|
if self.offline:
|
||||||
self._set_notice("offline mode — nothing to refresh", 3)
|
self._set_notice("offline mode — nothing to refresh", 3)
|
||||||
@@ -1009,7 +1323,16 @@ class BeefyPlayerApp(App[None]):
|
|||||||
self.query_one(ArtPane).resume()
|
self.query_one(ArtPane).resume()
|
||||||
|
|
||||||
def _cancel_workers(self) -> None:
|
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):
|
with contextlib.suppress(Exception):
|
||||||
self.workers.cancel_group(self, group)
|
self.workers.cancel_group(self, group)
|
||||||
|
|
||||||
@@ -1039,6 +1362,10 @@ class BeefyPlayerApp(App[None]):
|
|||||||
self.stations = []
|
self.stations = []
|
||||||
self.view = []
|
self.view = []
|
||||||
self.index = 0
|
self.index = 0
|
||||||
|
self.search_results = None
|
||||||
|
self._search_results_query = ""
|
||||||
|
self.search_pending = False
|
||||||
|
self._discovered_station_ids.clear()
|
||||||
self.current = None
|
self.current = None
|
||||||
self.schedule = []
|
self.schedule = []
|
||||||
self.schedule_loaded = False
|
self.schedule_loaded = False
|
||||||
@@ -1055,8 +1382,12 @@ class BeefyPlayerApp(App[None]):
|
|||||||
self._apply_catalogue_chrome()
|
self._apply_catalogue_chrome()
|
||||||
with contextlib.suppress(NoMatches):
|
with contextlib.suppress(NoMatches):
|
||||||
self._reset_filter()
|
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._set_stations(stations)
|
||||||
|
self._reset_directory_paging(stations, has_more=cached)
|
||||||
self._select_initial_station()
|
self._select_initial_station()
|
||||||
self._update_media()
|
self._update_media()
|
||||||
self._update_artwork()
|
self._update_artwork()
|
||||||
@@ -1096,15 +1427,27 @@ class BeefyPlayerApp(App[None]):
|
|||||||
if event.input.id != "search":
|
if event.input.id != "search":
|
||||||
return
|
return
|
||||||
self.query = event.value
|
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._rebuild_view()
|
||||||
self.query_one(StationList).set_filter(self.query)
|
self.query_one(StationList).set_filter(self.query)
|
||||||
self.query_one(StationList).refresh()
|
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:
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||||
if event.input.id != "search":
|
if event.input.id != "search":
|
||||||
return
|
return
|
||||||
station = self.view[self.index] if self.view else None
|
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:
|
if station is not None:
|
||||||
self._tune(station)
|
self._tune(station)
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import asyncio
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ from ..models import Broadcast, Song, Station
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"PLAYLIST_TTL",
|
"PLAYLIST_TTL",
|
||||||
"RadioProvider",
|
"RadioProvider",
|
||||||
|
"StationPage",
|
||||||
"fetch_json",
|
"fetch_json",
|
||||||
"is_direct_stream",
|
"is_direct_stream",
|
||||||
"is_playlist",
|
"is_playlist",
|
||||||
@@ -62,6 +64,15 @@ def reset_playlist_cache() -> None:
|
|||||||
_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
|
# the interface
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -114,6 +125,20 @@ class RadioProvider(ABC):
|
|||||||
#: stations that would rather not be scraped).
|
#: stations that would rather not be scraped).
|
||||||
bandcamp: bool = True
|
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 ------------------------------------------------------- #
|
# -- catalogue ------------------------------------------------------- #
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -128,6 +153,37 @@ class RadioProvider(ABC):
|
|||||||
def fallback_stations(self) -> list[Station]:
|
def fallback_stations(self) -> list[Station]:
|
||||||
"""A bundled list, so a fresh offline start still works."""
|
"""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 -------------------------------------------------------- #
|
# -- metadata -------------------------------------------------------- #
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""Radio Browser: a directory of the community's stations rather than a station.
|
"""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.
|
The most-clicked working relays are loaded a page at a time, and the rest of
|
||||||
There is no now-playing API, so this catalogue offers breadth, not history: pick
|
the 60,000-station directory is searched on the server as you type. There is no
|
||||||
a station and the stream speaks for itself.
|
now-playing API, so this catalogue offers breadth, not history: pick a station
|
||||||
|
and the stream speaks for itself.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -11,7 +12,7 @@ import httpx
|
|||||||
|
|
||||||
from ... import config
|
from ... import config
|
||||||
from ...models import Song, Station
|
from ...models import Song, Station
|
||||||
from ..base import RadioProvider
|
from ..base import RadioProvider, StationPage
|
||||||
from . import api, fallback
|
from . import api, fallback
|
||||||
from . import hero as hero_art
|
from . import hero as hero_art
|
||||||
|
|
||||||
@@ -23,8 +24,8 @@ class RadioBrowserProvider(RadioProvider):
|
|||||||
name = "Radio Browser"
|
name = "Radio Browser"
|
||||||
homepage = "radio-browser.info"
|
homepage = "radio-browser.info"
|
||||||
brand = "RADIO BROWSER"
|
brand = "RADIO BROWSER"
|
||||||
tagline = "community directory · 50,000 stations"
|
tagline = "community directory · live search"
|
||||||
station_source = "radio-browser.info (top 300)"
|
station_source = "radio-browser.info (paged + search)"
|
||||||
history_source = ""
|
history_source = ""
|
||||||
|
|
||||||
hero = hero_art.HERO
|
hero = hero_art.HERO
|
||||||
@@ -33,6 +34,14 @@ class RadioBrowserProvider(RadioProvider):
|
|||||||
# A directory has no show schedule and no Bandcamp worth scraping.
|
# A directory has no show schedule and no Bandcamp worth scraping.
|
||||||
bandcamp = False
|
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
|
@property
|
||||||
def user_agent(self) -> str:
|
def user_agent(self) -> str:
|
||||||
return f"{config.USER_AGENT}; +https://www.radio-browser.info"
|
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]:
|
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
|
||||||
return await api.fetch_stations(client)
|
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]:
|
def load_cached_stations(self) -> list[Station]:
|
||||||
return api.load_cached_stations()
|
return api.load_cached_stations()
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
|
|
||||||
``https://de1.api.radio-browser.info/json/stations/search`` is keyless, CORS-open
|
``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 —
|
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
|
there is no per-station history — so this catalogue is a *directory*: the most
|
||||||
hundred of the most-clicked working stations, ready to tune into. Whatever the
|
clicked working stations, a page at a time. Whatever the relay puts in its
|
||||||
relay puts in its stream is what you hear; the app has no metadata to show.
|
stream is what you hear; the app has no metadata to show.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
@@ -16,18 +18,34 @@ import httpx
|
|||||||
|
|
||||||
from ... import config
|
from ... import config
|
||||||
from ...models import Station, to_int
|
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"
|
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.
|
#: Stations per directory page. The app loads the first page at startup and
|
||||||
STATION_LIMIT = 300
|
#: 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 = {
|
_PARAMS = {
|
||||||
"hidebroken": "true",
|
"hidebroken": "true",
|
||||||
"order": "clickcount",
|
"order": "clickcount",
|
||||||
"reverse": "true",
|
"reverse": "true",
|
||||||
"limit": str(STATION_LIMIT),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_CODECS = {"mp3": "mp3", "aac": "aac", "aac+": "aacp", "ogg": "ogg", "opus": "opus"}
|
_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):
|
if not isinstance(payload, list):
|
||||||
return []
|
return []
|
||||||
stations: list[Station] = []
|
stations: list[Station] = []
|
||||||
@@ -92,22 +110,129 @@ def parse_stations(payload: Any) -> list[Station]:
|
|||||||
continue
|
continue
|
||||||
seen.add(station.id)
|
seen.add(station.id)
|
||||||
stations.append(station)
|
stations.append(station)
|
||||||
stations.sort(key=lambda station: station.title.casefold())
|
if sort:
|
||||||
|
stations.sort(key=lambda station: station.title.casefold())
|
||||||
return stations
|
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]:
|
async def fetch_stations(client: httpx.AsyncClient) -> list[Station]:
|
||||||
url = f"{API_URL}?{urlencode(_PARAMS)}"
|
page = await fetch_station_page(client, 0)
|
||||||
try:
|
if not page.stations:
|
||||||
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:
|
|
||||||
raise RuntimeError("Radio Browser returned no stations")
|
raise RuntimeError("Radio Browser returned no stations")
|
||||||
config.write_json(config.channels_cache_path("radiobrowser"), payload)
|
return page.stations
|
||||||
return stations
|
|
||||||
|
|
||||||
|
|
||||||
def load_cached_stations() -> list[Station]:
|
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"),
|
("m", "mute / unmute"),
|
||||||
("f", "favourite — favourites float to the top"),
|
("f", "favourite — favourites float to the top"),
|
||||||
("/", "filter stations by title, genre or description"),
|
("/", "filter stations by title, genre or description"),
|
||||||
("esc", "clear the filter / close overlays"),
|
("esc", "close the search box; esc again clears the filter"),
|
||||||
(
|
(
|
||||||
"v",
|
"v",
|
||||||
"cycle visualiser: mirror → bars → wave → waterfall → radial → braille",
|
"cycle visualiser: mirror → bars → wave → waterfall → radial → braille",
|
||||||
|
|||||||
@@ -88,7 +88,10 @@ class StationList(Widget):
|
|||||||
view = app.view
|
view = app.view
|
||||||
|
|
||||||
if not 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(
|
return frame.wrap(
|
||||||
[Text(truncate(message, inner_width), style="dim italic")],
|
[Text(truncate(message, inner_width), style="dim italic")],
|
||||||
width,
|
width,
|
||||||
|
|||||||
+314
-3
@@ -8,7 +8,8 @@ import pytest
|
|||||||
|
|
||||||
from beefy_player import config
|
from beefy_player import config
|
||||||
from beefy_player.app import BeefyPlayerApp
|
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.art import ArtPane
|
||||||
from beefy_player.widgets.help import HelpScreen
|
from beefy_player.widgets.help import HelpScreen
|
||||||
from beefy_player.widgets.stage import StagePanel
|
from beefy_player.widgets.stage import StagePanel
|
||||||
@@ -70,8 +71,10 @@ async def test_boot_navigation_filter_favourite_visualizer_help() -> None:
|
|||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
assert app.current is not None
|
assert app.current is not None
|
||||||
assert app.current.id == "defcon"
|
assert app.current.id == "defcon"
|
||||||
assert app.query == ""
|
assert app.query == "defcon"
|
||||||
assert len(app.view) == len(app.stations)
|
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")
|
assert app.playing_state in ("stopped", "error")
|
||||||
|
|
||||||
app.action_favorite()
|
app.action_favorite()
|
||||||
@@ -100,6 +103,314 @@ async def test_boot_navigation_filter_favourite_visualizer_help() -> None:
|
|||||||
assert "DEF CON" in text.upper()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_stage_renders_history_and_listener_count() -> None:
|
async def test_stage_renders_history_and_listener_count() -> None:
|
||||||
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
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"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_fetch_stations_caches_the_directory() -> None:
|
async def test_fetch_stations_caches_the_directory() -> None:
|
||||||
payload = [ENTRY, {**ENTRY, "stationuuid": "x", "name": "Another"}]
|
payload = [ENTRY, {**ENTRY, "stationuuid": "x", "name": "Another"}]
|
||||||
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
assert "hidebroken=true" in str(request.url)
|
params = dict(request.url.params)
|
||||||
assert "limit=300" in str(request.url)
|
assert params["hidebroken"] == "true"
|
||||||
|
assert params["limit"] == str(api.PAGE_SIZE)
|
||||||
|
assert params["offset"] == "0"
|
||||||
return httpx.Response(200, json=payload)
|
return httpx.Response(200, json=payload)
|
||||||
|
|
||||||
provider = RadioBrowserProvider()
|
provider = RadioBrowserProvider()
|
||||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
stations = await provider.fetch_stations(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()] == [
|
assert [station.title for station in provider.load_cached_stations()] == [
|
||||||
"Another",
|
|
||||||
"France Info",
|
"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
|
@pytest.mark.asyncio
|
||||||
async def test_a_failed_fetch_does_not_kill_the_catalogue() -> None:
|
async def test_a_failed_fetch_does_not_kill_the_catalogue() -> None:
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
|||||||
Reference in new issue
Block a user