"""Radio Browser: the community's own station directory. ``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*: 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 import httpx from ... import config from ...models import Station, to_int 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" #: 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", } _CODECS = {"mp3": "mp3", "aac": "aac", "aac+": "aacp", "ogg": "ogg", "opus": "opus"} def _quality(bitrate: int) -> str: if bitrate >= 192: return "highest" if bitrate >= 96: return "high" return "low" if bitrate else "" def station_from_entry(entry: dict[str, Any]) -> Station | None: if not isinstance(entry, dict): return None name = str(entry.get("name") or "").strip() url = str(entry.get("url_resolved") or entry.get("url") or "").strip() station_id = str(entry.get("stationuuid") or "").strip() if not name or not station_id or not url.startswith(("http://", "https://")): return None bitrate = to_int(entry.get("bitrate")) codec = str(entry.get("codec") or "").strip().lower() tags = [ tag.strip() for tag in str(entry.get("tags") or "").split(",") if tag.strip() ][:3] country = str(entry.get("country") or "").strip() language = str(entry.get("language") or "").strip() description = " · ".join(part for part in (country, language) if part) favicon = str(entry.get("favicon") or "").strip() return Station( id=station_id, title=name, description=description or "internet radio", genre="|".join(tags), image=favicon if favicon.startswith(("http://", "https://")) else "", playlists=[ { "url": url, "format": _CODECS.get(codec, codec), "quality": _quality(bitrate), "bitrate": str(bitrate) if bitrate else "", } ], provider="radiobrowser", ) def parse_stations(payload: Any, *, sort: bool = True) -> list[Station]: if not isinstance(payload, list): return [] stations: list[Station] = [] seen: set[str] = set() for entry in payload: station = station_from_entry(entry) if station is None or station.id in seen: continue seen.add(station.id) stations.append(station) 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]: page = await fetch_station_page(client, 0) if not page.stations: raise RuntimeError("Radio Browser returned no stations") return page.stations def load_cached_stations() -> list[Station]: 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