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

+54 -15
View File
@@ -7,11 +7,17 @@ song history, and full desktop media integration, drawn with your terminal's
own ANSI palette on the terminal's default background so it fits any theme
(including transparent terminals).
The station catalogue is pluggable. Beefy Player ships with
**[SomaFM](https://somafm.com)** (≈50 curated channels),
**[tilderadio](https://tilderadio.org)** (the tildeverse's single community
stream) and **[Bassdrive](https://bassdrive.com)** (worldwide drum & bass), and
the UI never talks to a station catalogue directly: it asks a
The station catalogue is pluggable. Beefy Player ships with thirteen catalogues
— **[SomaFM](https://somafm.com)** (≈50 curated channels),
**[Radio Paradise](https://radioparadise.com)** (five mixes), the community
stations **[tilderadio](https://tilderadio.org)**,
**[Bassdrive](https://bassdrive.com)**, **[dublab](https://dublab.com)**,
**[Rinse FM](https://rinse.fm)**, **[Refuge Worldwide](https://refugeworldwide.com)**,
**[LYL Radio](https://lyl.live)**, **[The Lot Radio](https://thelotradio.com)**,
**[Cashmere Radio](https://cashmereradio.com)**,
**[Datafruits](https://datafruits.fm)** and **[aNONradio](https://anonradio.net)**,
plus the **[Radio Browser](https://www.radio-browser.info)** directory
(300 stations) — and the UI never talks to a station catalogue directly: it asks a
[provider](#providers) for stations, song history and stream URLs. Press `p` to
switch catalogue without restarting. Adding another internet-radio catalogue is
a self-contained module, not a rewrite.
@@ -55,7 +61,9 @@ implemented here from public endpoints and open protocols.
- **Last played** — the full recent history, newest first.
- **Every station** — with SomaFM that is the whole `channels.json` roster
(≈50 channels), live listener counts and genre search; tilderadio is a
single-station catalogue, and the list simply holds one.
single-station catalogue, and the list simply holds one. Long lists get a grey
thumb down the right edge (no rail) and a `12/51` position in the pane title;
`pgup`/`pgdn` move a screen at a time.
- **Favourites** that float to the top, remembered between runs.
- **Pluggable catalogues** — one `providers` module per radio network, with a
`p` picker in the UI, `--provider` on the command line, and the choice
@@ -197,23 +205,46 @@ layout and the guessed icecast mirrors all live in
`beefy_player/providers/somafm/`. Nothing outside that package imports it; the
app only ever holds a `RadioProvider`.
Three catalogues ship today:
Thirteen catalogues ship today:
| slug | catalogue | station list | history | streams |
| slug | catalogue | stations | what is on | relays |
| --- | --- | --- | --- | --- |
| `somafm` | [SomaFM](https://somafm.com) | `channels.json` (≈50) | `songs/<station>.json` | `.pls` → icecast mirrors |
| `tilderadio` | [tilderadio](https://tilderadio.org) | `/api/now/` (1) | `now_playing` + `history[]` | AzuraCast mounts (mp3/ogg, 320k) |
| `bassdrive` | [Bassdrive](https://bassdrive.com) | icecast status (1) | rolling `display-title` | three mounts + mirrors |
| `radioparadise` | [Radio Paradise](https://radioparadise.com) | five mixes | `api/now_playing` (artist, album, cover) | FLAC / AAC / MP3 icecast |
| `tilderadio` | [tilderadio](https://tilderadio.org) | 1 + schedule | `/api/now/` | AzuraCast mounts |
| `bassdrive` | [Bassdrive](https://bassdrive.com) | 1 + schedule | icecast `display-title` (the show) | three mounts + mirrors |
| `dublab` | [dublab](https://dublab.com) | 1 + schedule | dublab's calendar | Airtime mp3 |
| `rinse` | [Rinse FM](https://rinse.fm) | 3 | — | proxy mounts (aac+) |
| `refuge` | [Refuge Worldwide](https://refugeworldwide.com) | 1 | `public.radio.co` status | radio.co mp3 |
| `lyl` | [LYL Radio](https://lyl.live) | 1 | — | icecast mp3 |
| `lot` | [The Lot Radio](https://thelotradio.com) | 1 | — | HLS |
| `cashmere` | [Cashmere Radio](https://cashmereradio.com) | 1 | — | Airtime ogg/mp3 |
| `datafruits` | [Datafruits](https://datafruits.fm) | 1 | streampusher icecast | relay mp3 |
| `anonradio` | [aNONradio](https://anonradio.net) | 1 | icecast title | icecast mp3 |
| `radiobrowser` | [Radio Browser](https://www.radio-browser.info) | 300 (directory) | — | whatever each station publishes |
They are deliberately different shapes: fifty channels with PLS playlists, a
single AzuraCast stream that carries its own history, and an Icecast relay whose
"now playing" is the *show* rather than the track. That is the seam working.
The single-stream catalogues also use the optional capabilities: their left
pane is the broadcast schedule rather than a list containing one station, and
they opt out of Bandcamp lookups. Bassdrive is the extreme case — Icecast
reports one title and keeps no history, so the provider maps the title to
`Station.dj`/`presenter` and keeps a rolling record of the shows it has heard.
The single-stream catalogues also use the optional capabilities: where a
schedule exists (`tilderadio`, `bassdrive`, `dublab`) the left pane is that
schedule rather than a list containing one station, and none of them do
Bandcamp lookups. Bassdrive is the extreme case — Icecast reports one title and
keeps no history, so the provider maps the title to `Station.dj`/`presenter`
and keeps a rolling record of the shows it has heard.
The community stations share one base class,
`providers/community/base.py`, which knows how to read an Icecast
`status-json.xsl`, a `public.radio.co` status, a scheduled show, or nothing at
all; each station is then ten lines of data (relays, wordmark, tagline). Where
a relay publishes no metadata (Rinse, LYL, The Lot, Cashmere) the app simply
plays it and says so, rather than inventing a track.
`radiobrowser` is the directory case: three hundred of the most-clicked working
stations from the community database, keyless and public-domain, with no
per-station history at all — breadth rather than depth, and the one catalogue
where ON AIR legitimately has nothing to say.
### Header wordmarks
@@ -306,6 +337,7 @@ The UI wants roughly **84×24**; it also works smaller, just less pretty.
| key | action |
| -------------- | --------------------------------------------- |
| `↑` / `↓`, `j` / `k` | move through stations |
| `pgup` / `pgdn` | page through stations |
| `enter` | tune in to the selected station |
| `space` | play / pause |
| `s` | stop |
@@ -363,8 +395,11 @@ beefy_player/
base.py RadioProvider interface + HTTP/playlist toolkit
wordmark.py the half-block font every hero is drawn with
somafm/ the SomaFM catalogue (API, hero, bundled fallback list)
radiobrowser/ the community directory (300 stations, no metadata)
radioparadise/ five curated mixes with real track metadata
tilderadio/ the tilderadio catalogue (one station, hero, schedule)
bassdrive/ Bassdrive (Icecast status, show schedule, rolling history)
community/ one module per community station, on a shared base
player.py mpv over a Unix-socket JSON IPC channel
spectrum.py analyser (parec / Core Audio tap / ffmpeg / sim)
macos_tap.py macOS output capture (ctypes + Core Audio)
@@ -404,6 +439,10 @@ tools/
values. A memoised fetch and a lock keep a cold start to one homepage
download; the display-title's show is matched against the running slot for
"live from <city>".
- `beefy_player/providers/community/` — the community stations: `base.py` reads
Icecast or radio.co status, keeps the rolling history and turns a running
schedule slot into "what is on"; each station module is data. `rinse.py`
shows the multi-station case and `dublab.py` the schedule case.
- `beefy_player/providers/wordmark.py` — the half-block capitals behind every
hero, with per-character style spans.
- `beefy_player/widgets/schedule.py` — the left pane for single-stream
+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
+2 -2
View File
@@ -1,13 +1,13 @@
[project]
name = "beefy-player"
version = "0.1.0"
description = "A flashy terminal player for internet radio — live spectrum analyser, MPRIS / macOS Now Playing support, song history and pluggable station catalogues (SomaFM, tilderadio and Bassdrive included)"
description = "A flashy terminal player for internet radio — live spectrum analyser, MPRIS / macOS Now Playing support, song history and pluggable station catalogues (SomaFM, Radio Paradise, dublab, Rinse FM and more)"
readme = "README.md"
license = "WTFPL"
license-files = ["LICENSE"]
requires-python = ">=3.10"
authors = [{ name = "beefy-player contributors" }]
keywords = ["radio", "internet-radio", "tui", "terminal", "mpris", "audio", "somafm", "tilderadio", "bassdrive"]
keywords = ["radio", "internet-radio", "tui", "terminal", "mpris", "audio", "somafm", "tilderadio", "bassdrive", "radioparadise", "radiobrowser", "dublab"]
classifiers = [
"Environment :: Console",
"Intended Audience :: End Users/Desktop",
+3
View File
@@ -82,6 +82,9 @@ Version=2
assert base.is_playlist("https://x/y.pls?foo=1") is True
assert base.is_direct_stream("https://ice1.somafm.com/x-128-mp3") is True
assert base.is_direct_stream("https://x/y.m3u") is False
# HLS is the player's job, not ours: mpv reads the playlist itself
assert base.is_playlist("https://x/live/index.m3u8") is False
assert base.is_direct_stream("https://x/live/index.m3u8") is True
def test_parse_songs_and_dates() -> None:
+223
View File
@@ -0,0 +1,223 @@
"""The community-station providers: relays, status documents and schedules."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import httpx
import pytest
from beefy_player.providers import PROVIDERS
from beefy_player.providers.community import (
AnonradioProvider,
CashmereProvider,
DatafruitsProvider,
DublabProvider,
LotProvider,
LylProvider,
RefugeProvider,
RinseProvider,
dublab,
)
ICECAST = {
"icestats": {
"source": [
{
"listenurl": "https://anonradio.net:8443/anonradio",
"bitrate": 192,
"server_type": "audio/mpeg",
"listeners": 13,
"title": "repeating screwtape from 29-Nov-2023",
}
]
}
}
RADIO_CO = {
"status": "online",
"listeners": 41,
"current_track": {
"title": "Guest Show (r) - Ain Autavg",
"start_time": "2026-11-04T00:00:02+00:00",
"artwork_url": "https://images.radio.co/station.png",
},
}
def client_for(payload, status_code: int = 200) -> httpx.AsyncClient:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(status_code, json=payload)
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
def test_every_community_provider_builds_a_playable_station() -> None:
providers = (
AnonradioProvider,
DatafruitsProvider,
CashmereProvider,
LylProvider,
LotProvider,
RefugeProvider,
DublabProvider,
RinseProvider,
)
for cls in providers:
provider = cls()
stations = provider.fallback_stations()
assert stations, cls.id
for station in stations:
assert station.provider == provider.id
assert station.playlists, (cls.id, station.id)
assert all(url.startswith(("http://", "https://")) for url in provider.fallback_urls(station))
# every one of them spells its name in the same font
assert len(provider.hero) == 3, cls.id
def test_rinse_is_a_family_of_three_stations() -> None:
stations = RinseProvider().fallback_stations()
assert [station.id for station in stations] == ["rinse_uk", "kool", "swu"]
assert [station.title for station in stations] == ["Rinse UK", "Kool London", "SWU"]
for station in stations:
assert "admin.stream.rinse.fm/proxy/" in station.playlists[0]["url"]
assert station.format_label.endswith("128k")
def test_the_lot_streams_hls_which_the_player_owns() -> None:
from beefy_player.providers.base import is_direct_stream, is_playlist
url = LotProvider().fallback_urls(LotProvider().station())[0]
assert url.endswith(".m3u8")
assert is_playlist(url) is False
assert is_direct_stream(url) is True
@pytest.mark.asyncio
async def test_icecast_status_gives_the_station_what_is_on() -> None:
provider = AnonradioProvider()
async with client_for(ICECAST) as client:
station = (await provider.fetch_stations(client))[0]
assert station.listeners == 13
assert station.now_playing is not None
assert station.now_playing.title == "repeating screwtape from 29-Nov-2023"
@pytest.mark.asyncio
async def test_radio_co_status_gives_a_track_and_its_art() -> None:
provider = RefugeProvider()
async with client_for(RADIO_CO) as client:
station = (await provider.fetch_stations(client))[0]
history = await provider.fetch_history(client, "refuge")
assert station.listeners == 41
assert station.now_playing is not None
assert station.now_playing.album == ""
assert station.now_playing.art_url.endswith("station.png")
assert history[0].title == "Ain Autavg"
# the show's own start time is used, not "just now"
assert history[0].played_at == datetime(
2026, 11, 4, 0, 0, 2, tzinfo=timezone.utc
)
@pytest.mark.asyncio
async def test_history_is_rolling_and_keeps_the_first_sighting() -> None:
provider = AnonradioProvider()
async with client_for(ICECAST) as client:
first = await provider.fetch_history(client, "anonradio")
again = await provider.fetch_history(client, "anonradio")
assert len(first) == 1
assert again[0].played_at == first[0].played_at
@pytest.mark.asyncio
async def test_a_station_without_metadata_still_carries_on() -> None:
"""Rinse, LYL, The Lot and Cashmere publish no status document."""
provider = RinseProvider()
async with client_for({}, status_code=500) as client:
stations = await provider.fetch_stations(client)
history = await provider.fetch_history(client, "rinse_uk")
assert len(stations) == 3
assert all(station.now_playing is None for station in stations)
assert history == []
def test_dublab_schedule_parses_the_site_calendar() -> None:
payload = [
{
"startTime": "2026-11-03T16:00:00-08:00",
"endTime": "2026-11-03T18:00:00-08:00",
"summary": "LIVE // Version Sounds",
},
{
"startTime": "2026-11-03T18:00:00-08:00",
"endTime": "2026-11-03T20:00:00-08:00",
"summary": "PRE-REC // Sounds of Now",
},
]
shows = dublab.parse_schedule(payload)
assert [show.name for show in shows] == [
"LIVE // Version Sounds",
"PRE-REC // Sounds of Now",
]
assert shows[0].start == datetime(
2026, 11, 3, 16, tzinfo=timezone(timedelta(hours=-8))
)
assert dublab.parse_schedule({"nope": True}) == []
def test_the_running_slot_is_marked_live() -> None:
provider = DublabProvider()
now = datetime.now(timezone.utc)
running = dublab.Broadcast(
name="LIVE // Version Sounds",
start=now - timedelta(minutes=30),
end=now + timedelta(minutes=30),
)
later = dublab.Broadcast(
name="Later Show",
start=now + timedelta(hours=1),
end=now + timedelta(hours=2),
)
marked = provider._mark_live([running, later])
assert [item.live for item in marked] == [True, False]
assert marked[0].key() == running.key()
@pytest.mark.asyncio
async def test_dublab_history_follows_the_schedule() -> None:
provider = DublabProvider()
now = datetime.now(timezone.utc)
payload = [
{
"startTime": (now - timedelta(minutes=20)).isoformat(),
"endTime": (now + timedelta(minutes=40)).isoformat(),
"summary": "LIVE // Version Sounds",
}
]
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
await provider.fetch_schedule(client)
history = await provider.fetch_history(client, "dublab")
assert history and history[0].title == "LIVE // Version Sounds"
def test_every_catalogue_is_registered_under_its_slug() -> None:
assert set(PROVIDERS) == {
"somafm",
"tilderadio",
"bassdrive",
"radioparadise",
"dublab",
"rinse",
"refuge",
"lyl",
"lot",
"radiobrowser",
"cashmere",
"datafruits",
"anonradio",
}
+23 -2
View File
@@ -22,7 +22,22 @@ def rendered_text(app) -> str:
def test_registry_lists_somafm_as_the_default() -> None:
assert available_providers() == ("somafm", "tilderadio", "bassdrive")
# the menu is alphabetical by name: aNONradio ... tilderadio
assert available_providers() == (
"anonradio",
"bassdrive",
"cashmere",
"datafruits",
"dublab",
"lyl",
"radiobrowser",
"radioparadise",
"refuge",
"rinse",
"somafm",
"lot",
"tilderadio",
)
assert isinstance(get_provider(), SomaFMProvider)
assert get_provider("somafm") is get_provider()
assert isinstance(get_provider("tilderadio"), TilderadioProvider)
@@ -177,7 +192,13 @@ async def test_p_opens_the_picker_and_switches_catalogue() -> None:
await pilot.pause()
assert isinstance(app.screen, ProviderScreen)
await pilot.press("2") # jump straight to tilderadio
# walk down the alphabetical menu from the current entry to tilderadio
slugs = available_providers()
steps = (slugs.index("tilderadio") - slugs.index("somafm")) % len(slugs)
for _ in range(steps):
await pilot.press("down")
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
await pilot.pause()
+113
View File
@@ -0,0 +1,113 @@
"""Radio Browser: a directory of stations, not a station."""
from __future__ import annotations
import httpx
import pytest
from beefy_player.providers.radiobrowser import RadioBrowserProvider, api, fallback
ENTRY = {
"stationuuid": "96062a7b-0601-11e8-ae97-52543be04c81",
"name": "France Info",
"url_resolved": "http://direct.franceinfo.fr/live/franceinfo-midfi.mp3",
"url": "http://direct.franceinfo.fr/live/franceinfo-midfi.mp3",
"country": "France",
"language": "french",
"tags": "news,talk,politic",
"codec": "MP3",
"bitrate": 128,
"favicon": "https://www.francetvinfo.fr/icon.png",
}
def test_an_entry_becomes_a_station() -> None:
station = api.station_from_entry(ENTRY)
assert station is not None
assert station.id == ENTRY["stationuuid"]
assert station.title == "France Info"
assert station.description == "France · french"
assert station.genre_label == "news · talk · politic"
assert station.image.endswith("icon.png")
assert station.format_label == "mp3 · 128k"
assert station.provider == "radiobrowser"
def test_entries_without_a_stream_or_a_name_are_skipped() -> None:
assert api.station_from_entry({**ENTRY, "url_resolved": "", "url": ""}) is None
assert api.station_from_entry({**ENTRY, "name": " "}) is None
assert api.station_from_entry({**ENTRY, "stationuuid": ""}) is None
assert api.station_from_entry("not a dict") is None
def test_parse_dedupes_and_sorts_by_name() -> None:
payload = [
{**ENTRY, "stationuuid": "b", "name": "Zed FM"},
{**ENTRY, "stationuuid": "a", "name": "Alpha Radio"},
{**ENTRY, "stationuuid": "a", "name": "Alpha Radio"},
]
stations = api.parse_stations(payload)
assert [station.title for station in stations] == ["Alpha Radio", "Zed FM"]
assert api.parse_stations(None) == []
assert api.parse_stations({}) == []
def test_hls_streams_survive_as_direct_urls() -> None:
hls = {
**ENTRY,
"stationuuid": "hls",
"url_resolved": "https://example.test/live/index.m3u8?token=1",
"codec": "AAC",
"bitrate": 64,
}
station = api.station_from_entry(hls)
assert station is not None
assert station.playlists[0]["format"] == "aac"
assert station.format_label == "aac · 64k"
@pytest.mark.asyncio
async def test_fetch_stations_caches_the_directory() -> None:
payload = [ENTRY, {**ENTRY, "stationuuid": "x", "name": "Another"}]
def handler(request: httpx.Request) -> httpx.Response:
assert "hidebroken=true" in str(request.url)
assert "limit=300" in str(request.url)
return httpx.Response(200, json=payload)
provider = RadioBrowserProvider()
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
stations = await provider.fetch_stations(client)
assert [station.title for station in stations] == ["Another", "France Info"]
assert [station.title for station in provider.load_cached_stations()] == [
"Another",
"France Info",
]
@pytest.mark.asyncio
async def test_a_failed_fetch_does_not_kill_the_catalogue() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, json={})
provider = RadioBrowserProvider()
with pytest.raises(RuntimeError):
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
await provider.fetch_stations(client)
def test_the_bundled_directory_works_offline() -> None:
providers = fallback.fallback_stations()
assert len(providers) == 10
for station in providers:
assert station.playlists and station.provider == "radiobrowser"
assert station.playlists[0]["url"].startswith("https://")
def test_the_directory_offers_no_history_and_no_schedule() -> None:
provider = RadioBrowserProvider()
assert provider.has_schedule is False
assert provider.bandcamp is False
assert len(provider.hero) == 3
assert provider.hero_lines[0] == "RADIO BROWSER"
assert provider.load_cached_history("anything") == []
+90
View File
@@ -0,0 +1,90 @@
"""Radio Paradise: five mixes, one now-playing API, rolling history."""
from __future__ import annotations
import httpx
import pytest
from beefy_player.providers.radioparadise import RadioParadiseProvider, api
NOW = {
"time": 244,
"artist": "Eric Bibb",
"title": "Don't Ever Let Nobody Drag Your Spirit Down",
"album": "Painting Signs",
"year": "1999",
"cover": "https://img.radioparadise.com/covers/l/1.jpg",
}
def test_parse_now_keeps_the_cover_art() -> None:
song = api.parse_now(NOW)
assert song is not None
assert (song.artist, song.title) == ("Eric Bibb", NOW["title"])
assert song.album == "Painting Signs"
assert song.art_url.endswith("1.jpg")
assert api.parse_now({}) is None
assert api.parse_now(None) is None
def test_playlists_prefer_flac_and_carry_real_bitrates() -> None:
playlists = api.playlists(("flac", "aac-320", "mp3-320", "mp3-128"))
assert [entry["url"].rsplit("/", 1)[1] for entry in playlists] == [
"flac",
"aac-320",
"mp3-320",
"mp3-128",
]
assert [entry["quality"] for entry in playlists] == [
"highest",
"high",
"high",
"high",
]
assert playlists[0]["bitrate"] == "1411"
assert playlists[-1]["bitrate"] == "128"
def test_five_mixes_with_channel_numbers() -> None:
provider = RadioParadiseProvider()
stations = provider.fallback_stations()
assert [station.id for station in stations] == [
"main",
"mellow",
"rock",
"global",
"beyond",
]
assert api.CHANNEL_BY_STATION["beyond"] == 5
assert stations[0].format_label.startswith("flac")
# the alternates are FLAC-only relays
assert [entry["url"].rsplit("/", 1)[1] for entry in stations[1].playlists] == [
"mellow-flac"
]
@pytest.mark.asyncio
async def test_history_polls_the_right_channel_and_rolls() -> None:
seen: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(str(request.url))
return httpx.Response(200, json=NOW)
provider = RadioParadiseProvider()
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
first = await provider.fetch_history(client, "mellow")
again = await provider.fetch_history(client, "mellow")
assert any("chan=1" in url for url in seen)
assert len(first) == 1
assert again[0].key() == first[0].key()
assert again[0].played_at == first[0].played_at # first sighting kept
assert provider.load_cached_stations()[0].title == "The Main Mix"
@pytest.mark.asyncio
async def test_an_unknown_station_has_no_history() -> None:
provider = RadioParadiseProvider()
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, json=NOW))) as client:
assert await provider.fetch_history(client, "no-such-mix") == []