239 lines
7.5 KiB
Python
239 lines
7.5 KiB
Python
"""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
|