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

@@ -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
]