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)