feat(radiobrowser): add search, paging and favourite lookup

This commit is contained in:
beefcarver committed 2026-11-04 15:11:42 -05:00
1 parent f96923b5b7
commit 638cc9eddb
8 files changed
+1018 -45

No files matched your search

+354 -11
View File
@@ -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)
+56
View File
@@ -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()
+144 -19
View File
@@ -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
+1 -1
View File
@@ -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",
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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: