feat(providers): add nine catalogues and the Radio Browser directory
This commit is contained in:
1 parent
a3ad693132
commit
49f7efe0d2
27 files changed
+1695
-22
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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()]
|
||||
@@ -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")
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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") == []
|
||||
@@ -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") == []
|
||||
Reference in new issue
Block a user