feat(providers): add nine catalogues and the Radio Browser directory
This commit is contained in:
1 parent
a3ad693132
commit
49f7efe0d2
27 files changed
+1695
-22
No files matched your search
@@ -0,0 +1,59 @@
|
||||
"""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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from ... import config
|
||||
from ...models import Song, Station
|
||||
from ..base import RadioProvider
|
||||
from . import api, fallback
|
||||
from . import hero as hero_art
|
||||
|
||||
__all__ = ["RadioBrowserProvider"]
|
||||
|
||||
|
||||
class RadioBrowserProvider(RadioProvider):
|
||||
id = "radiobrowser"
|
||||
name = "Radio Browser"
|
||||
homepage = "radio-browser.info"
|
||||
brand = "RADIO BROWSER"
|
||||
tagline = "community directory · 50,000 stations"
|
||||
station_source = "radio-browser.info (top 300)"
|
||||
history_source = ""
|
||||
|
||||
hero = hero_art.HERO
|
||||
hero_lines = ("RADIO BROWSER", "COMMUNITY DIRECTORY", "KEYLESS · OPEN DATA")
|
||||
|
||||
# A directory has no show schedule and no Bandcamp worth scraping.
|
||||
bandcamp = False
|
||||
|
||||
@property
|
||||
def user_agent(self) -> str:
|
||||
return f"{config.USER_AGENT}; +https://www.radio-browser.info"
|
||||
|
||||
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
|
||||
return await api.fetch_stations(client)
|
||||
|
||||
def load_cached_stations(self) -> list[Station]:
|
||||
return api.load_cached_stations()
|
||||
|
||||
def fallback_stations(self) -> list[Station]:
|
||||
return fallback.fallback_stations()
|
||||
|
||||
async def fetch_history(
|
||||
self, client: httpx.AsyncClient, station_id: str
|
||||
) -> list[Song]:
|
||||
# Radio Browser publishes no now-playing data at all.
|
||||
return []
|
||||
|
||||
def load_cached_history(self, station_id: str) -> list[Song]:
|
||||
return []
|
||||
|
||||
def fallback_urls(self, station: Station) -> list[str]:
|
||||
return [entry["url"] for entry in station.sorted_playlists()]
|
||||
@@ -0,0 +1,113 @@
|
||||
"""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*: 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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from ... import config
|
||||
from ...models import Station, to_int
|
||||
from ..base import fetch_json
|
||||
|
||||
API_URL = "https://de1.api.radio-browser.info/json/stations/search"
|
||||
|
||||
#: Three hundred is a comfortable directory: enough to browse, quick to fetch.
|
||||
STATION_LIMIT = 300
|
||||
|
||||
_PARAMS = {
|
||||
"hidebroken": "true",
|
||||
"order": "clickcount",
|
||||
"reverse": "true",
|
||||
"limit": str(STATION_LIMIT),
|
||||
}
|
||||
|
||||
_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) -> 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)
|
||||
stations.sort(key=lambda station: station.title.casefold())
|
||||
return stations
|
||||
|
||||
|
||||
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:
|
||||
raise RuntimeError("Radio Browser returned no stations")
|
||||
config.write_json(config.channels_cache_path("radiobrowser"), payload)
|
||||
return stations
|
||||
|
||||
|
||||
def load_cached_stations() -> list[Station]:
|
||||
return parse_stations(config.read_json(config.channels_cache_path("radiobrowser")))
|
||||
@@ -0,0 +1,44 @@
|
||||
"""A handful of directory entries for a cold offline start.
|
||||
|
||||
Radio Browser itself is the directory, but an offline start has no cache — so
|
||||
this is ten stations whose relays have been up for years, one per taste.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ...models import Station
|
||||
|
||||
#: (id, title, country, genre tags, url, format, bitrate)
|
||||
_STATIONS: tuple[tuple[str, str, str, str, str, str, str], ...] = (
|
||||
("fallback-groovesalad", "SomaFM Groove Salad", "United States", "ambient|chill", "https://ice1.somafm.com/groovesalad-128-mp3", "mp3", "128"),
|
||||
("fallback-radioparadise", "Radio Paradise", "United States", "eclectic|rock", "https://stream.radioparadise.com/flac", "flac", "1411"),
|
||||
("fallback-fip", "FIP", "France", "eclectic|jazz", "https://icecast.radiofrance.fr/fip-midfi.mp3", "mp3", "128"),
|
||||
("fallback-kexp", "KEXP", "United States", "alternative|indie", "https://kexp.streamguys1.com/kexp160.aac", "aac", "160"),
|
||||
("fallback-nts1", "NTS Radio 1", "United Kingdom", "eclectic|electronic", "https://stream-relay-geo.ntslive.net/stream", "mp3", "128"),
|
||||
("fallback-rinse", "Rinse UK", "United Kingdom", "house|electronic", "https://admin.stream.rinse.fm/proxy/rinse_uk/stream", "aacp", "128"),
|
||||
("fallback-dublab", "dublab", "United States", "eclectic|experimental", "https://dublab.out.airtime.pro/dublab_a", "mp3", "192"),
|
||||
("fallback-cashmere", "Cashmere Radio", "Germany", "experimental", "https://cashmereradio.out.airtime.pro/cashmereradio_a", "ogg", "192"),
|
||||
("fallback-datafruits", "Datafruits", "United States", "electronic|experimental", "https://streampusher-relay.club/datafruits.mp3", "mp3", "128"),
|
||||
("fallback-anonradio", "aNONradio", "United States", "eclectic|talk", "https://anonradio.net:8443/anonradio", "mp3", "192"),
|
||||
)
|
||||
|
||||
|
||||
def fallback_stations() -> list[Station]:
|
||||
return [
|
||||
Station(
|
||||
id=station_id,
|
||||
title=title,
|
||||
description=country,
|
||||
genre=genre,
|
||||
playlists=[
|
||||
{
|
||||
"url": url,
|
||||
"format": fmt,
|
||||
"quality": "high",
|
||||
"bitrate": bitrate,
|
||||
}
|
||||
],
|
||||
provider="radiobrowser",
|
||||
)
|
||||
for station_id, title, country, genre, url, fmt, bitrate in _STATIONS
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Radio Browser's header hero: the directory's own name."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..wordmark import render
|
||||
|
||||
HERO: tuple[str, ...] = render("RADIOBROWSER")
|
||||
Reference in new issue
Block a user