feat(radiobrowser): add search, paging and favourite lookup
This commit is contained in:
1 parent
f96923b5b7
commit
638cc9eddb
8 files changed
+1018
-45
No files matched your search
@@ -1,8 +1,9 @@
|
||||
"""Radio Browser: a directory of the community's stations rather than a station.
|
||||
|
||||
Three hundred of the most-clicked working relays, keyless and public-domain.
|
||||
There is no now-playing API, so this catalogue offers breadth, not history: pick
|
||||
a station and the stream speaks for itself.
|
||||
The most-clicked working relays are loaded a page at a time, and the rest of
|
||||
the 60,000-station directory is searched on the server as you type. There is no
|
||||
now-playing API, so this catalogue offers breadth, not history: pick a station
|
||||
and the stream speaks for itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -11,7 +12,7 @@ import httpx
|
||||
|
||||
from ... import config
|
||||
from ...models import Song, Station
|
||||
from ..base import RadioProvider
|
||||
from ..base import RadioProvider, StationPage
|
||||
from . import api, fallback
|
||||
from . import hero as hero_art
|
||||
|
||||
@@ -23,8 +24,8 @@ class RadioBrowserProvider(RadioProvider):
|
||||
name = "Radio Browser"
|
||||
homepage = "radio-browser.info"
|
||||
brand = "RADIO BROWSER"
|
||||
tagline = "community directory · 50,000 stations"
|
||||
station_source = "radio-browser.info (top 300)"
|
||||
tagline = "community directory · live search"
|
||||
station_source = "radio-browser.info (paged + search)"
|
||||
history_source = ""
|
||||
|
||||
hero = hero_art.HERO
|
||||
@@ -33,6 +34,14 @@ class RadioBrowserProvider(RadioProvider):
|
||||
# A directory has no show schedule and no Bandcamp worth scraping.
|
||||
bandcamp = False
|
||||
|
||||
# The full directory is much larger than the list fetched at startup; the
|
||||
# app asks the API when the filter changes.
|
||||
server_search = True
|
||||
|
||||
# The directory is loaded a page at a time, in click-count order.
|
||||
paginated = True
|
||||
preserve_order = True
|
||||
|
||||
@property
|
||||
def user_agent(self) -> str:
|
||||
return f"{config.USER_AGENT}; +https://www.radio-browser.info"
|
||||
@@ -40,6 +49,21 @@ class RadioBrowserProvider(RadioProvider):
|
||||
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
|
||||
return await api.fetch_stations(client)
|
||||
|
||||
async def fetch_station_page(
|
||||
self, client: httpx.AsyncClient, offset: int = 0
|
||||
) -> StationPage:
|
||||
return await api.fetch_station_page(client, offset)
|
||||
|
||||
async def fetch_stations_by_ids(
|
||||
self, client: httpx.AsyncClient, station_ids: list[str]
|
||||
) -> list[Station]:
|
||||
return await api.fetch_stations_by_ids(client, station_ids)
|
||||
|
||||
async def search_stations(
|
||||
self, client: httpx.AsyncClient, query: str
|
||||
) -> list[Station]:
|
||||
return await api.search_stations(client, query)
|
||||
|
||||
def load_cached_stations(self) -> list[Station]:
|
||||
return api.load_cached_stations()
|
||||
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
``https://de1.api.radio-browser.info/json/stations/search`` is keyless, CORS-open
|
||||
and public-domain data. It knows nothing about what a station is playing —
|
||||
there is no per-station history — so this catalogue is a *directory*: three
|
||||
hundred of the most-clicked working stations, ready to tune into. Whatever the
|
||||
relay puts in its stream is what you hear; the app has no metadata to show.
|
||||
there is no per-station history — so this catalogue is a *directory*: the most
|
||||
clicked working stations, a page at a time. Whatever the relay puts in its
|
||||
stream is what you hear; the app has no metadata to show.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
@@ -16,18 +18,34 @@ import httpx
|
||||
|
||||
from ... import config
|
||||
from ...models import Station, to_int
|
||||
from ..base import fetch_json
|
||||
from ..base import StationPage, fetch_json
|
||||
|
||||
API_URL = "https://de1.api.radio-browser.info/json/stations/search"
|
||||
BY_UUID_URL = "https://de1.api.radio-browser.info/json/stations/byuuid"
|
||||
|
||||
#: Three hundred is a comfortable directory: enough to browse, quick to fetch.
|
||||
STATION_LIMIT = 300
|
||||
#: Stations per directory page. The app loads the first page at startup and
|
||||
#: the next ones as the station list nears its end.
|
||||
PAGE_SIZE = 100
|
||||
|
||||
#: Results per field for a directory search. The four fields are merged.
|
||||
SEARCH_LIMIT = 100
|
||||
|
||||
#: The search endpoint has no full-text parameter, so a free-text query is
|
||||
#: sent once per field and the answers are merged.
|
||||
_SEARCH_FIELDS = ("name", "tag", "country", "language")
|
||||
|
||||
#: Radio Browser ids are UUIDs; anything else is another catalogue's favourite.
|
||||
_UUID_PATTERN = re.compile(
|
||||
r"^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$"
|
||||
)
|
||||
|
||||
#: How many UUIDs to ask for in one ``byuuid`` request.
|
||||
LOOKUP_BATCH = 50
|
||||
|
||||
_PARAMS = {
|
||||
"hidebroken": "true",
|
||||
"order": "clickcount",
|
||||
"reverse": "true",
|
||||
"limit": str(STATION_LIMIT),
|
||||
}
|
||||
|
||||
_CODECS = {"mp3": "mp3", "aac": "aac", "aac+": "aacp", "ogg": "ogg", "opus": "opus"}
|
||||
@@ -81,7 +99,7 @@ def station_from_entry(entry: dict[str, Any]) -> Station | None:
|
||||
)
|
||||
|
||||
|
||||
def parse_stations(payload: Any) -> list[Station]:
|
||||
def parse_stations(payload: Any, *, sort: bool = True) -> list[Station]:
|
||||
if not isinstance(payload, list):
|
||||
return []
|
||||
stations: list[Station] = []
|
||||
@@ -92,22 +110,129 @@ def parse_stations(payload: Any) -> list[Station]:
|
||||
continue
|
||||
seen.add(station.id)
|
||||
stations.append(station)
|
||||
stations.sort(key=lambda station: station.title.casefold())
|
||||
if sort:
|
||||
stations.sort(key=lambda station: station.title.casefold())
|
||||
return stations
|
||||
|
||||
|
||||
def _page_url(offset: int) -> str:
|
||||
params = {**_PARAMS, "limit": str(PAGE_SIZE), "offset": str(offset)}
|
||||
return f"{API_URL}?{urlencode(params)}"
|
||||
|
||||
|
||||
async def fetch_station_page(
|
||||
client: httpx.AsyncClient, offset: int = 0
|
||||
) -> StationPage:
|
||||
"""One page of the directory, most-clicked first."""
|
||||
try:
|
||||
payload = await fetch_json(client, _page_url(offset))
|
||||
except RuntimeError as exc:
|
||||
raise RuntimeError(f"could not fetch the Radio Browser directory: {exc}") from None
|
||||
|
||||
stations = parse_stations(payload, sort=False)
|
||||
rows = len(payload) if isinstance(payload, list) else 0
|
||||
next_offset = offset + PAGE_SIZE if rows >= PAGE_SIZE else None
|
||||
if offset == 0 and stations and isinstance(payload, list):
|
||||
config.write_json(config.channels_cache_path("radiobrowser"), payload)
|
||||
return StationPage(stations=stations, next_offset=next_offset)
|
||||
|
||||
|
||||
async def fetch_stations(client: httpx.AsyncClient) -> list[Station]:
|
||||
url = f"{API_URL}?{urlencode(_PARAMS)}"
|
||||
try:
|
||||
payload = await fetch_json(client, url)
|
||||
except RuntimeError as exc:
|
||||
raise RuntimeError(f"could not fetch the Radio Browser directory: {exc}") from None
|
||||
stations = parse_stations(payload)
|
||||
if not stations:
|
||||
page = await fetch_station_page(client, 0)
|
||||
if not page.stations:
|
||||
raise RuntimeError("Radio Browser returned no stations")
|
||||
config.write_json(config.channels_cache_path("radiobrowser"), payload)
|
||||
return stations
|
||||
return page.stations
|
||||
|
||||
|
||||
def load_cached_stations() -> list[Station]:
|
||||
return parse_stations(config.read_json(config.channels_cache_path("radiobrowser")))
|
||||
return parse_stations(
|
||||
config.read_json(config.channels_cache_path("radiobrowser")), sort=False
|
||||
)
|
||||
|
||||
|
||||
async def _search_field(
|
||||
client: httpx.AsyncClient, query: str, field: str
|
||||
) -> list[Station]:
|
||||
params = {**_PARAMS, "limit": str(SEARCH_LIMIT), field: query}
|
||||
payload = await fetch_json(client, f"{API_URL}?{urlencode(params)}")
|
||||
return parse_stations(payload, sort=False)
|
||||
|
||||
|
||||
async def search_stations(client: httpx.AsyncClient, query: str) -> list[Station]:
|
||||
"""Search the directory by name, tag, country and language.
|
||||
|
||||
The API has no single full-text search, so the query is sent once per field
|
||||
and the results are merged. A station that matches a genre rarely has that
|
||||
genre in its name, and vice versa. Fields that fail are ignored as long as
|
||||
one of them still answers; only a complete failure is raised.
|
||||
"""
|
||||
text = query.strip()
|
||||
if not text:
|
||||
return []
|
||||
|
||||
batches = await asyncio.gather(
|
||||
*(_search_field(client, text, field) for field in _SEARCH_FIELDS),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
stations: list[Station] = []
|
||||
seen: set[str] = set()
|
||||
failures = 0
|
||||
for batch in batches:
|
||||
if isinstance(batch, BaseException):
|
||||
failures += 1
|
||||
continue
|
||||
for station in batch:
|
||||
if station.id in seen:
|
||||
continue
|
||||
seen.add(station.id)
|
||||
stations.append(station)
|
||||
|
||||
if failures == len(batches):
|
||||
raise RuntimeError("could not search the Radio Browser directory")
|
||||
|
||||
return stations
|
||||
|
||||
|
||||
async def _fetch_by_uuids(
|
||||
client: httpx.AsyncClient, station_ids: list[str]
|
||||
) -> list[Station]:
|
||||
params = urlencode({"uuids": ",".join(station_ids)}, safe=",")
|
||||
payload = await fetch_json(client, f"{BY_UUID_URL}?{params}")
|
||||
return parse_stations(payload, sort=False)
|
||||
|
||||
|
||||
async def fetch_stations_by_ids(
|
||||
client: httpx.AsyncClient, station_ids: list[str]
|
||||
) -> list[Station]:
|
||||
"""Look up specific stations, for favourites outside the first page."""
|
||||
ids = list(
|
||||
dict.fromkeys(
|
||||
station_id.strip()
|
||||
for station_id in station_ids
|
||||
if _UUID_PATTERN.match(station_id.strip())
|
||||
)
|
||||
)
|
||||
if not ids:
|
||||
return []
|
||||
|
||||
batches = [
|
||||
ids[index : index + LOOKUP_BATCH]
|
||||
for index in range(0, len(ids), LOOKUP_BATCH)
|
||||
]
|
||||
results = await asyncio.gather(
|
||||
*(_fetch_by_uuids(client, batch) for batch in batches),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
stations: list[Station] = []
|
||||
seen: set[str] = set()
|
||||
for batch in results:
|
||||
if isinstance(batch, BaseException):
|
||||
continue
|
||||
for station in batch:
|
||||
if station.id in seen:
|
||||
continue
|
||||
seen.add(station.id)
|
||||
stations.append(station)
|
||||
return stations
|
||||
Reference in new issue
Block a user