feat(providers): add nine catalogues and the Radio Browser directory

This commit is contained in:
beefcarver committed 2026-11-03 20:24:53 -05:00
1 parent a3ad693132
commit 49f7efe0d2
27 files changed
+1695 -22

No files matched your search

+32 -2
View File
@@ -18,6 +18,18 @@ from __future__ import annotations
from .base import RadioProvider
from .bassdrive import BassdriveProvider
from .community import (
AnonradioProvider,
CashmereProvider,
DatafruitsProvider,
DublabProvider,
LotProvider,
LylProvider,
RefugeProvider,
RinseProvider,
)
from .radiobrowser import RadioBrowserProvider
from .radioparadise import RadioParadiseProvider
from .somafm import SomaFMProvider
from .tilderadio import TilderadioProvider
@@ -29,10 +41,28 @@ __all__ = [
"get_provider",
]
#: Every catalogue the app can talk to, keyed by its slug.
#: Every catalogue the app can talk to, keyed by its slug. The picker, the
#: help screen and ``--list`` all read this order: alphabetical by name.
PROVIDERS: dict[str, RadioProvider] = {
provider.id: provider
for provider in (SomaFMProvider(), TilderadioProvider(), BassdriveProvider())
for provider in sorted(
(
SomaFMProvider(),
TilderadioProvider(),
BassdriveProvider(),
RadioBrowserProvider(),
RadioParadiseProvider(),
DublabProvider(),
RinseProvider(),
RefugeProvider(),
LylProvider(),
LotProvider(),
CashmereProvider(),
DatafruitsProvider(),
AnonradioProvider(),
),
key=lambda provider: provider.name.casefold(),
)
}
#: Provider used when nothing else is configured.
+2 -1
View File
@@ -194,8 +194,9 @@ class RadioProvider(ABC):
def is_playlist(url: str) -> bool:
"""A playlist *we* resolve; HLS is handed to the player untouched."""
path = url.split("?", 1)[0].split("#", 1)[0].lower()
return path.endswith((".pls", ".m3u", ".m3u8"))
return path.endswith((".pls", ".m3u"))
def is_direct_stream(url: str) -> bool:
@@ -0,0 +1,29 @@
"""Independent stations: one relay each, with the metadata the relay offers.
Each module here is a thin subclass of
:class:`beefy_player.providers.community.base.CommunityProvider`, which knows
how to read an Icecast status document or a radio.co status, keep a rolling
history and (for Dublab) follow a published schedule.
"""
from __future__ import annotations
from .anonradio import AnonradioProvider
from .cashmere import CashmereProvider
from .datafruits import DatafruitsProvider
from .dublab import DublabProvider
from .lot import LotProvider
from .lyl import LylProvider
from .refuge import RefugeProvider
from .rinse import RinseProvider
__all__ = [
"AnonradioProvider",
"CashmereProvider",
"DatafruitsProvider",
"DublabProvider",
"LotProvider",
"LylProvider",
"RefugeProvider",
"RinseProvider",
]
@@ -0,0 +1,26 @@
"""aNONradio: SDF's community station, on Icecast since the dial was young."""
from __future__ import annotations
from .base import CommunityProvider
class AnonradioProvider(CommunityProvider):
id = "anonradio"
name = "aNONradio"
homepage = "anonradio.net"
brand = "aNONradio"
tagline = "SDF community radio"
station_source = "anonradio.net icecast"
history_source = "icecast title (rolling)"
station_title = "aNONradio"
station_description = (
"community station of the SDF public-access UNIX system"
)
station_genre = "eclectic|talk"
streams = (("https://anonradio.net:8443/anonradio", "mp3", "high", "192"),)
status_url = "https://anonradio.net:8443/status-json.xsl"
wordmark = "ANONRADIO"
hero_lines = ("SDF COMMUNITY RADIO", "PUBLIC ACCESS UNIX")
+336
View File
@@ -0,0 +1,336 @@
"""Community stations: one relay each, sometimes with a status document.
Most independent stations are a single stream on Icecast, radio.co or Airtime,
plus — if you are lucky — a status document that names what is on. This module
turns that into a provider: the station, its relays, and a rolling history of
whatever the relay has announced while you were listening.
A subclass only carries data. Stations with a schedule or a multi-station
catalogue (Dublab, Rinse, Radio Paradise) override a hook or two.
"""
from __future__ import annotations
import re
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import httpx
from ... import config
from ...models import Broadcast, Song, Station, parse_date, parse_last_playing, to_int
from ..base import RadioProvider, fetch_json
from ..wordmark import render
#: ``public.radio.co`` status document: keyless, no station id in the body.
RADIO_CO_STATUS = "https://public.radio.co/stations/{station}/status"
#: How many shows/tracks LAST PLAYED remembers.
HISTORY_LIMIT = 20
class CommunityProvider(RadioProvider):
"""A catalogue that broadcasts one stream (or a fixed handful)."""
# -- catalogue data (set by each station module) ---------------------- #
station_title = ""
station_description = ""
station_genre = "eclectic"
station_image = ""
#: ``(url, format, quality, bitrate)`` — the relays, best first.
streams: tuple[tuple[str, str, str, str], ...] = ()
#: Keyless Icecast ``status-json.xsl``, when the relay publishes one.
status_url = ""
#: ``public.radio.co`` station id, when the relay is radio.co.
radio_co_id = ""
#: Wordmark the header spells; empty leaves the provider without a hero.
wordmark = ""
# Independent stations are not Bandcamp catalogues.
bandcamp = False
def __init__(self) -> None:
if self.wordmark:
self.hero = render(self.wordmark)
# ------------------------------------------------------------------ #
# station
# ------------------------------------------------------------------ #
def _playlists(self) -> list[dict[str, str]]:
return [
{"url": url, "format": fmt, "quality": quality, "bitrate": bitrate}
for url, fmt, quality, bitrate in self.streams
]
def station(self) -> Station:
return Station(
id=self.id,
title=self.station_title or self.name,
description=self.station_description or self.tagline,
genre=self.station_genre,
image=self.station_image,
playlists=self._playlists(),
provider=self.id,
)
def stations(self) -> list[Station]:
return [self.station()]
# ------------------------------------------------------------------ #
# what the relay says is on
# ------------------------------------------------------------------ #
@property
def has_status(self) -> bool:
return bool(self.status_url or self.radio_co_id)
async def _status(self, client: httpx.AsyncClient, attempts: int = 2) -> Any:
url = (
RADIO_CO_STATUS.format(station=self.radio_co_id)
if self.radio_co_id
else self.status_url
)
return await fetch_json(client, url, attempts=attempts)
def _listeners(self, payload: Any) -> int:
if not isinstance(payload, dict):
return 0
if self.radio_co_id:
return to_int(payload.get("listeners"))
stats = payload.get("icestats")
source = stats.get("source") if isinstance(stats, dict) else None
sources = (
source
if isinstance(source, list)
else [source]
if isinstance(source, dict)
else []
)
return sum(
to_int(item.get("listeners")) for item in sources if isinstance(item, dict)
)
def _icecast_title(self, payload: Any) -> str:
stats = payload.get("icestats") if isinstance(payload, dict) else None
source = stats.get("source") if isinstance(stats, dict) else None
sources = (
source
if isinstance(source, list)
else [source]
if isinstance(source, dict)
else []
)
for item in sources:
if not isinstance(item, dict):
continue
for key in ("display-title", "title", "yp_currently_playing"):
value = item.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def _now(self, payload: Any) -> Song | None:
"""The relay's current track/show, as a :class:`Song`."""
if not isinstance(payload, dict):
return None
if self.radio_co_id:
track = payload.get("current_track")
if not isinstance(track, dict):
return None
title = str(track.get("title") or "").strip()
if not title:
return None
song = parse_last_playing(title) or Song(title=title)
song.art_url = str(track.get("artwork_url") or "").strip()
song.played_at = parse_date(track.get("start_time"))
return song
title = self._icecast_title(payload)
if not title:
return None
return parse_last_playing(title)
# ------------------------------------------------------------------ #
# the schedule, when the catalogue has one (Dublab)
# ------------------------------------------------------------------ #
def _scheduled_now(self) -> Broadcast | None:
now = datetime.now(timezone.utc)
for entry in self.load_cached_schedule():
if entry.start is None or entry.end is None:
continue
if entry.start <= now < entry.end:
return entry
return None
def _mark_live(self, entries: list[Broadcast]) -> list[Broadcast]:
"""Flag the slot that is running now, so the pane can say ON AIR."""
now = datetime.now(timezone.utc)
marked: list[Broadcast] = []
for entry in entries:
if (
entry.start is not None
and entry.end is not None
and entry.start <= now < entry.end
):
marked.append(
Broadcast(
name=entry.name,
dj=entry.dj,
location=entry.location,
start=entry.start,
end=entry.end,
live=True,
)
)
else:
marked.append(entry)
return marked
def _scheduled_song(self) -> Song | None:
entry = self._scheduled_now()
if entry is None:
return None
return Song(artist=self.name, title=entry.name)
# ------------------------------------------------------------------ #
# caching
# ------------------------------------------------------------------ #
def _station_cache(self) -> Path:
return config.channels_cache_path(self.id)
def _songs_cache(self, station_id: str) -> Path:
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", station_id or self.id)
return config.SONGS_CACHE_DIR / f"{self.id}-{safe}.json"
def _songs_to_cache(self, songs: list[Song]) -> list[dict[str, str]]:
return [
{
"title": song.title,
"artist": song.artist,
"art": song.art_url,
"played_at": song.played_at.isoformat() if song.played_at else "",
}
for song in songs
]
# ------------------------------------------------------------------ #
# RadioProvider
# ------------------------------------------------------------------ #
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
stations = self.stations()
payload: Any = None
if self.has_status:
try:
payload = await self._status(client)
except RuntimeError:
payload = None
if payload is None:
payload = self._cached_status()
else:
config.write_json(
self._station_cache(), {"fetched": time.time(), "status": payload}
)
song = self._now(payload) if payload is not None else None
if song is None:
song = self._scheduled_song()
listeners = self._listeners(payload) if payload is not None else 0
entry = self._scheduled_now()
for station in stations:
if song is not None:
station.now_playing = song
station.last_playing = (
f"{song.artist} - {song.title}" if song.artist else song.title
)
if listeners:
station.listeners = listeners
if entry is not None:
station.presenter = (
f"live from {entry.location}" if entry.location else "live now"
)
return stations
def _cached_status(self) -> Any:
raw = config.read_json(self._station_cache())
return raw.get("status") if isinstance(raw, dict) else None
def load_cached_stations(self) -> list[Station]:
stations = self.stations()
payload = self._cached_status()
song = self._now(payload) if payload is not None else None
if song is None:
song = self._scheduled_song()
listeners = self._listeners(payload) if payload is not None else 0
for station in stations:
if song is not None:
station.now_playing = song
if listeners:
station.listeners = listeners
return stations
def fallback_stations(self) -> list[Station]:
return self.stations()
async def fetch_history(
self, client: httpx.AsyncClient, station_id: str
) -> list[Song]:
current: Song | None = None
if self.has_status:
try:
payload = await self._status(client, attempts=1)
except RuntimeError:
payload = None
if payload is not None:
current = self._now(payload)
if current is None:
current = self._scheduled_song()
if current is None:
return self.load_cached_history(station_id)
history = self.load_cached_history(station_id)
now = datetime.now(timezone.utc)
if history and history[0].key() == current.key():
# Same show or track: keep the time we first heard it.
current.played_at = history[0].played_at
history = [current, *history[1:]]
else:
current.played_at = current.played_at or now
history = [current, *[s for s in history if s.key() != current.key()]]
history = history[:HISTORY_LIMIT]
config.write_json(
self._songs_cache(station_id),
{"fetched": time.time(), "songs": self._songs_to_cache(history)},
)
return history
def load_cached_history(self, station_id: str) -> list[Song]:
raw = config.read_json(self._songs_cache(station_id))
entries = raw.get("songs") if isinstance(raw, dict) else None
if not isinstance(entries, list):
return []
songs: list[Song] = []
for entry in entries:
if not isinstance(entry, dict):
continue
song = Song(
title=str(entry.get("title") or "").strip(),
artist=str(entry.get("artist") or "").strip(),
art_url=str(entry.get("art") or "").strip(),
played_at=parse_date(entry.get("played_at")),
)
if song.title or song.artist:
songs.append(song)
return songs
def fallback_urls(self, station: Station) -> list[str]:
return [url for url, _format, _quality, _bitrate in self.streams]
@@ -0,0 +1,26 @@
"""Cashmere Radio: Berlin's experimental station, on an Airtime relay."""
from __future__ import annotations
from .base import CommunityProvider
class CashmereProvider(CommunityProvider):
id = "cashmere"
name = "Cashmere Radio"
homepage = "cashmereradio.com"
brand = "CASHMERE"
tagline = "experimental radio · berlin"
station_source = "cashmereradio.com"
history_source = ""
station_title = "Cashmere Radio"
station_description = "experimental radio from Berlin"
station_genre = "experimental|eclectic"
streams = (
("https://cashmereradio.out.airtime.pro/cashmereradio_a", "ogg", "high", "192"),
("https://cashmereradio.out.airtime.pro/cashmereradio_b", "mp3", "high", "192"),
)
wordmark = "CASHMERE"
hero_lines = ("EXPERIMENTAL RADIO", "BERLIN")
@@ -0,0 +1,24 @@
"""Datafruits: the netlabel's internet radio, relayed by Streampusher."""
from __future__ import annotations
from .base import CommunityProvider
class DatafruitsProvider(CommunityProvider):
id = "datafruits"
name = "Datafruits"
homepage = "datafruits.fm"
brand = "DATAFRUITS"
tagline = "netlabel radio · experimental"
station_source = "streampusher relay + icecast status"
history_source = "icecast title (rolling)"
station_title = "Datafruits"
station_description = "internet radio from the Datafruits netlabel"
station_genre = "electronic|experimental"
streams = (("https://streampusher-relay.club/datafruits.mp3", "mp3", "high", "128"),)
status_url = "https://streampusher-relay.club/status-json.xsl"
wordmark = "DATAFRUITS"
hero_lines = ("NETLABEL RADIO", "EXPERIMENTAL ELECTRONIC")
+108
View File
@@ -0,0 +1,108 @@
"""dublab: Los Angeles community radio, with a public weekly schedule."""
from __future__ import annotations
import contextlib
import time
from datetime import datetime, timezone
from typing import Any
import httpx
from ... import config
from ...models import Broadcast, parse_date
from ..base import fetch_json
from .base import CommunityProvider
SCHEDULE_URL = "https://dublab.com/.netlify/functions/schedule"
def parse_schedule(payload: Any) -> list[Broadcast]:
"""The site's own calendar: ``[{startTime, endTime, summary, ...}]``."""
if not isinstance(payload, list):
return []
broadcasts: list[Broadcast] = []
for entry in payload:
if not isinstance(entry, dict):
continue
name = str(entry.get("summary") or entry.get("title") or "").strip()
start = parse_date(entry.get("startTime") or entry.get("start"))
end = parse_date(entry.get("endTime") or entry.get("end"))
if not name or start is None:
continue
broadcasts.append(Broadcast(name=name, start=start, end=end))
broadcasts.sort(key=lambda item: item.start or datetime.now(timezone.utc))
return broadcasts
class DublabProvider(CommunityProvider):
id = "dublab"
name = "dublab"
homepage = "dublab.com"
brand = "DUBLAB"
tagline = "community radio · los angeles · since 1999"
station_source = "dublab.com schedule"
history_source = "dublab.com schedule (rolling)"
station_title = "dublab"
station_description = "non-profit community radio from Los Angeles, since 1999"
station_genre = "eclectic|experimental"
streams = (("https://dublab.out.airtime.pro/dublab_a", "mp3", "high", "192"),)
# The schedule is the only metadata dublab publishes, so it drives both the
# left pane and "what is on".
has_schedule = True
wordmark = "DUBLAB"
hero_lines = ("LOS ANGELES", "SINCE 1999")
async def fetch_schedule(self, client: httpx.AsyncClient) -> list[Broadcast]:
try:
payload = await fetch_json(client, SCHEDULE_URL, attempts=1)
except RuntimeError:
return self.load_cached_schedule()
entries = self._mark_live(parse_schedule(payload))
if not entries:
return self.load_cached_schedule()
config.write_json(
config.schedule_cache_path(self.id),
{
"fetched": time.time(),
"schedule": [
{
"name": item.name,
"start": item.start.isoformat() if item.start else "",
"end": item.end.isoformat() if item.end else "",
}
for item in entries
],
},
)
return entries
async def fetch_history(self, client: httpx.AsyncClient, station_id: str):
# The schedule is the only "what is on" dublab publishes, so make sure
# we have one before the base class looks for the running show.
if not self.load_cached_schedule():
with contextlib.suppress(Exception):
await self.fetch_schedule(client)
return await super().fetch_history(client, station_id)
def load_cached_schedule(self) -> list[Broadcast]:
raw = config.read_json(config.schedule_cache_path(self.id))
entries = raw.get("schedule") if isinstance(raw, dict) else None
if not isinstance(entries, list):
return []
broadcasts: list[Broadcast] = []
for entry in entries:
if not isinstance(entry, dict):
continue
start = parse_date(entry.get("start"))
name = str(entry.get("name") or "").strip()
if not name or start is None:
continue
broadcasts.append(
Broadcast(name=name, start=start, end=parse_date(entry.get("end")))
)
broadcasts.sort(key=lambda item: item.start or datetime.now(timezone.utc))
return broadcasts
+31
View File
@@ -0,0 +1,31 @@
"""The Lot Radio: live from a shipping container in Brooklyn (HLS)."""
from __future__ import annotations
from .base import CommunityProvider
class LotProvider(CommunityProvider):
id = "lot"
name = "The Lot Radio"
homepage = "thelotradio.com"
brand = "THELOT"
tagline = "live from the lot · brooklyn"
station_source = "thelotradio.com (HLS)"
history_source = ""
station_title = "The Lot Radio"
station_description = "live radio from a shipping container in Brooklyn"
station_genre = "eclectic|house"
# The station streams HLS; mpv plays the playlist directly.
streams = (
(
"https://livepeercdn.studio/hls/85c28sa2o8wppm58/index.m3u8",
"aac",
"high",
"",
),
)
wordmark = "THELOT"
hero_lines = ("BROOKLYN", "LIVE FROM THE LOT")
+23
View File
@@ -0,0 +1,23 @@
"""LYL Radio: independent webradio from Lyon and Paris."""
from __future__ import annotations
from .base import CommunityProvider
class LylProvider(CommunityProvider):
id = "lyl"
name = "LYL Radio"
homepage = "lyl.live"
brand = "LYL"
tagline = "we're the rest · lyon · paris"
station_source = "icecast.lyl.live"
history_source = ""
station_title = "LYL Radio"
station_description = "independent webradio from Lyon and Paris"
station_genre = "eclectic|experimental"
streams = (("https://icecast.lyl.live/live", "mp3", "high", "192"),)
wordmark = "LYL"
hero_lines = ("WE'RE THE REST", "LYON · PARIS")
@@ -0,0 +1,24 @@
"""Refuge Worldwide: Berlin community radio on a radio.co relay."""
from __future__ import annotations
from .base import CommunityProvider
class RefugeProvider(CommunityProvider):
id = "refuge"
name = "Refuge Worldwide"
homepage = "refugeworldwide.com"
brand = "REFUGE"
tagline = "community radio · berlin"
station_source = "radio.co status"
history_source = "radio.co now playing (rolling)"
station_title = "Refuge Worldwide"
station_description = "community radio from Berlin"
station_genre = "eclectic|electronic"
streams = (("https://streaming.radio.co/s3699c5e49/listen", "mp3", "high", "192"),)
radio_co_id = "s3699c5e49"
wordmark = "REFUGE"
hero_lines = ("COMMUNITY RADIO", "BERLIN")
+66
View File
@@ -0,0 +1,66 @@
"""Rinse FM: London's station family, a proxy mount each."""
from __future__ import annotations
from ...models import Station
from .base import CommunityProvider
#: (station id, title, genre, mount, description)
_STATIONS: tuple[tuple[str, str, str, str, str], ...] = (
(
"rinse_uk",
"Rinse UK",
"electronic|house",
"rinse_uk",
"London's underground dance station, on air since 1994",
),
(
"kool",
"Kool London",
"jungle|drum & bass",
"kool",
"Kool London: jungle, drum & bass and roots",
),
(
"swu",
"SWU",
"soul|eclectic",
"swu",
"SWU: soul, funk and everything south west",
),
)
class RinseProvider(CommunityProvider):
id = "rinse"
name = "Rinse FM"
homepage = "rinse.fm"
brand = "RINSE"
tagline = "london · since 1994"
station_source = "rinse.fm streams"
history_source = ""
station_genre = "electronic"
wordmark = "RINSE"
hero_lines = ("LONDON", "SINCE 1994")
def stations(self) -> list[Station]:
return [
Station(
id=station_id,
title=title,
description=description,
genre=genre,
playlists=[
{
"url": f"https://admin.stream.rinse.fm/proxy/{mount}/stream",
"format": "aacp",
"quality": "high",
"bitrate": "128",
}
],
provider=self.id,
)
for station_id, title, genre, mount, description in _STATIONS
]
@@ -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()]
+113
View File
@@ -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")
@@ -0,0 +1,136 @@
"""Radio Paradise: five hand-curated mixes, listener-supported since 2000.
Unlike the community stations, Radio Paradise publishes a proper now-playing
API for every mix, with album and cover art — so this provider is a small
catalogue with real track metadata rather than a single relay.
"""
from __future__ import annotations
import re
import time
from datetime import datetime, timezone
from pathlib import Path
import httpx
from ... import config
from ...models import Song, Station
from ..base import RadioProvider
from ..wordmark import render
from . import api
__all__ = ["RadioParadiseProvider"]
#: How many tracks LAST PLAYED remembers per mix.
HISTORY_LIMIT = 20
class RadioParadiseProvider(RadioProvider):
id = "radioparadise"
name = "Radio Paradise"
homepage = "radioparadise.com"
brand = "RADIO PARADISE"
tagline = "listener-supported · curated mixes"
station_source = "api.radioparadise.com/now_playing"
history_source = "api.radioparadise.com (rolling)"
hero = render("PARADISE")
hero_lines = ("RADIO PARADISE", "LISTENER SUPPORTED", "PARADISE, CALIFORNIA")
@property
def user_agent(self) -> str:
return f"{config.USER_AGENT}; +https://radioparadise.com"
def stations(self) -> list[Station]:
return [
Station(
id=station_id,
title=title,
description=f"Radio Paradise — {title}",
genre=genre,
playlists=api.playlists(names),
provider=self.id,
)
for station_id, _chan, title, genre, names in api.CHANNELS
]
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
return self.stations()
def load_cached_stations(self) -> list[Station]:
return self.stations()
def fallback_stations(self) -> list[Station]:
return self.stations()
def _songs_cache(self, station_id: str) -> Path:
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", station_id or self.id)
return config.SONGS_CACHE_DIR / f"{self.id}-{safe}.json"
async def fetch_history(
self, client: httpx.AsyncClient, station_id: str
) -> list[Song]:
chan = api.CHANNEL_BY_STATION.get(station_id)
if chan is None:
return self.load_cached_history(station_id)
current = await api.fetch_now(client, chan)
if current is None:
return self.load_cached_history(station_id)
history = self.load_cached_history(station_id)
if history and history[0].key() == current.key():
current.played_at = history[0].played_at
history = [current, *history[1:]]
else:
current.played_at = datetime.now(timezone.utc)
history = [current, *[song for song in history if song.key() != current.key()]]
history = history[:HISTORY_LIMIT]
config.write_json(
self._songs_cache(station_id),
{
"fetched": time.time(),
"songs": [
{
"title": song.title,
"artist": song.artist,
"album": song.album,
"art": song.art_url,
"played_at": song.played_at.isoformat()
if song.played_at
else "",
}
for song in history
],
},
)
return history
def load_cached_history(self, station_id: str) -> list[Song]:
raw = config.read_json(self._songs_cache(station_id))
entries = raw.get("songs") if isinstance(raw, dict) else None
if not isinstance(entries, list):
return []
songs: list[Song] = []
for entry in entries:
if not isinstance(entry, dict):
continue
song = Song(
title=str(entry.get("title") or "").strip(),
artist=str(entry.get("artist") or "").strip(),
album=str(entry.get("album") or "").strip(),
art_url=str(entry.get("art") or "").strip(),
played_at=None,
)
if isinstance(entry.get("played_at"), str) and entry["played_at"]:
from ...models import parse_date
song.played_at = parse_date(entry["played_at"])
if song.title or song.artist:
songs.append(song)
return songs
def fallback_urls(self, station: Station) -> list[str]:
return [entry["url"] for entry in station.sorted_playlists()]
@@ -0,0 +1,85 @@
"""Radio Paradise's public endpoints, and nothing but.
* ``https://api.radioparadise.com/api/now_playing?chan=N`` -> the current track
(artist, title, album, year, cover art, seconds remaining)
* ``https://stream.radioparadise.com/<name>`` -> the icecast relays
Radio Paradise curates five mixes; each is one relay and one channel number on
the now-playing API. The main mix carries lossy relays as well as FLAC; the
alternate mixes are FLAC only.
"""
from __future__ import annotations
from typing import Any
import httpx
from ...models import Song
from ..base import fetch_json
NOW_URL = "https://api.radioparadise.com/api/now_playing?chan={chan}"
STREAM_URL = "https://stream.radioparadise.com/{name}"
FORMAT_LABELS = {"flac": ("flac", "highest"), "aac-320": ("aac", "high")}
#: (station id, channel, title, genre, relay names)
CHANNELS: tuple[tuple[str, int, str, str, tuple[str, ...]], ...] = (
(
"main",
0,
"The Main Mix",
"eclectic|rock",
("flac", "aac-320", "mp3-320", "mp3-128"),
),
("mellow", 1, "Mellow Mix", "chill|eclectic", ("mellow-flac",)),
("rock", 2, "RockIt!", "rock|alternative", ("rock-flac",)),
("global", 3, "The Globe", "world|eclectic", ("global-flac",)),
("beyond", 5, "Beyond...", "ambient|experimental", ("beyond-flac",)),
)
CHANNEL_BY_STATION = {station_id: chan for station_id, chan, *_ in CHANNELS}
def parse_now(payload: Any) -> Song | None:
if not isinstance(payload, dict):
return None
artist = str(payload.get("artist") or "").strip()
title = str(payload.get("title") or "").strip()
if not (artist or title):
return None
return Song(
artist=artist,
title=title,
album=str(payload.get("album") or "").strip(),
art_url=str(payload.get("cover") or payload.get("cover_med") or "").strip(),
)
async def fetch_now(client: httpx.AsyncClient, chan: int) -> Song | None:
try:
payload = await fetch_json(client, NOW_URL.format(chan=chan), attempts=1)
except RuntimeError:
return None
return parse_now(payload)
def playlists(names: tuple[str, ...]) -> list[dict[str, str]]:
"""The relay list for one mix, best first."""
entries: list[dict[str, str]] = []
for name in names:
fmt, quality = FORMAT_LABELS.get(name, (name.split("-")[0], "high"))
bitrate = ""
if fmt == "flac":
bitrate = "1411" # nominal CD-quality FLAC rate
elif name.startswith(("aac-", "mp3-")):
bitrate = name.split("-", 1)[1]
entries.append(
{
"url": STREAM_URL.format(name=name),
"format": fmt,
"quality": quality,
"bitrate": bitrate,
}
)
return entries
@@ -0,0 +1,7 @@
"""Radio Paradise's header hero: the one word it is named after."""
from __future__ import annotations
from ..wordmark import render
HERO: tuple[str, ...] = render("PARADISE")
+9
View File
@@ -18,18 +18,27 @@ from collections.abc import Sequence
GLYPHS: dict[str, tuple[str, ...]] = {
"A": (".##.", "#..#", "####", "#..#", "#..#", "#..#"),
"B": ("###.", "#..#", "###.", "#..#", "#..#", "###."),
"C": (".##.", "#..#", "#...", "#...", "#..#", ".##."),
"D": ("###.", "#..#", "#..#", "#..#", "#..#", "###."),
"E": ("####", "#...", "###.", "#...", "#...", "####"),
"F": ("####", "#...", "###.", "#...", "#...", "#..."),
"G": (".##.", "#..#", "#...", "#.##", "#..#", ".##."),
"H": ("#..#", "#..#", "####", "#..#", "#..#", "#..#"),
"I": ("###", ".#.", ".#.", ".#.", ".#.", "###"),
"K": ("#..#", "#.#.", "##..", "##..", "#.#.", "#..#"),
"L": ("#...", "#...", "#...", "#...", "#...", "####"),
# 5 wide on purpose: the M is the odd one out in the original wordmark.
"M": ("#...#", "##.##", "#.#.#", "#.#.#", "#...#", "#...#"),
"N": ("#..#", "##.#", "##.#", "#.##", "#..#", "#..#"),
"O": (".##.", "#..#", "#..#", "#..#", "#..#", ".##."),
"P": ("###.", "#..#", "###.", "#...", "#...", "#..."),
"R": ("###.", "#..#", "###.", "#.#.", "#..#", "#..#"),
"S": (".###", "#...", "###.", "...#", "...#", "###."),
"T": ("###", ".#.", ".#.", ".#.", ".#.", ".#."),
"U": ("#..#", "#..#", "#..#", "#..#", "#..#", ".##."),
"V": ("#..#", "#..#", "#..#", "#..#", ".##.", ".##."),
"W": ("#...#", "#...#", "#.#.#", "#.#.#", "##.##", "#...#"),
"Y": ("#..#", "#..#", ".##.", "..#.", "..#.", "..#."),
}
ROWS = 3