diff --git a/beefy_player/app.py b/beefy_player/app.py index 104a137..a7f12ff 100644 --- a/beefy_player/app.py +++ b/beefy_player/app.py @@ -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) diff --git a/beefy_player/providers/base.py b/beefy_player/providers/base.py index 56d0471..7656fef 100644 --- a/beefy_player/providers/base.py +++ b/beefy_player/providers/base.py @@ -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 diff --git a/beefy_player/providers/radiobrowser/__init__.py b/beefy_player/providers/radiobrowser/__init__.py index c3a1bae..81cc5ec 100644 --- a/beefy_player/providers/radiobrowser/__init__.py +++ b/beefy_player/providers/radiobrowser/__init__.py @@ -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() diff --git a/beefy_player/providers/radiobrowser/api.py b/beefy_player/providers/radiobrowser/api.py index 39081b5..cad5fd9 100644 --- a/beefy_player/providers/radiobrowser/api.py +++ b/beefy_player/providers/radiobrowser/api.py @@ -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 diff --git a/beefy_player/widgets/help.py b/beefy_player/widgets/help.py index e5de41c..0ab073f 100644 --- a/beefy_player/widgets/help.py +++ b/beefy_player/widgets/help.py @@ -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", diff --git a/beefy_player/widgets/station_list.py b/beefy_player/widgets/station_list.py index e9a8b43..cabdc44 100644 --- a/beefy_player/widgets/station_list.py +++ b/beefy_player/widgets/station_list.py @@ -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, diff --git a/tests/test_app.py b/tests/test_app.py index 4690d6c..3e75d53 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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) diff --git a/tests/test_radiobrowser.py b/tests/test_radiobrowser.py index 7e602c9..b46d199 100644 --- a/tests/test_radiobrowser.py +++ b/tests/test_radiobrowser.py @@ -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: