Compare commits

..
2 Commits
41 changed files with 2266 additions and 86 deletions

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 own ANSI palette on the terminal's default background so it fits any theme
(including transparent terminals). (including transparent terminals).
The station catalogue is pluggable. Beefy Player ships with The station catalogue is pluggable. Beefy Player ships with thirteen catalogues
**[SomaFM](https://somafm.com)** (≈50 curated channels), — **[SomaFM](https://somafm.com)** (≈50 curated channels),
**[tilderadio](https://tilderadio.org)** (the tildeverse's single community **[Radio Paradise](https://radioparadise.com)** (five mixes), the community
stream) and **[Bassdrive](https://bassdrive.com)** (worldwide drum & bass), and stations **[tilderadio](https://tilderadio.org)**,
the UI never talks to a station catalogue directly: it asks a **[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 [provider](#providers) for stations, song history and stream URLs. Press `p` to
switch catalogue without restarting. Adding another internet-radio catalogue is switch catalogue without restarting. Adding another internet-radio catalogue is
a self-contained module, not a rewrite. 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. - **Last played** — the full recent history, newest first.
- **Every station** — with SomaFM that is the whole `channels.json` roster - **Every station** — with SomaFM that is the whole `channels.json` roster
(≈50 channels), live listener counts and genre search; tilderadio is a (≈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. - **Favourites** that float to the top, remembered between runs.
- **Pluggable catalogues** — one `providers` module per radio network, with a - **Pluggable catalogues** — one `providers` module per radio network, with a
`p` picker in the UI, `--provider` on the command line, and the choice `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 `beefy_player/providers/somafm/`. Nothing outside that package imports it; the
app only ever holds a `RadioProvider`. 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 | | `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) | | `radioparadise` | [Radio Paradise](https://radioparadise.com) | five mixes | `api/now_playing` (artist, album, cover) | FLAC / AAC / MP3 icecast |
| `bassdrive` | [Bassdrive](https://bassdrive.com) | icecast status (1) | rolling `display-title` | three mounts + mirrors | | `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 They are deliberately different shapes: fifty channels with PLS playlists, a
single AzuraCast stream that carries its own history, and an Icecast relay whose 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. "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 The single-stream catalogues also use the optional capabilities: where a
pane is the broadcast schedule rather than a list containing one station, and schedule exists (`tilderadio`, `bassdrive`, `dublab`) the left pane is that
they opt out of Bandcamp lookups. Bassdrive is the extreme case — Icecast schedule rather than a list containing one station, and none of them do
reports one title and keeps no history, so the provider maps the title to Bandcamp lookups. Bassdrive is the extreme case — Icecast reports one title and
`Station.dj`/`presenter` and keeps a rolling record of the shows it has heard. 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 ### Header wordmarks
@@ -306,6 +337,7 @@ The UI wants roughly **84×24**; it also works smaller, just less pretty.
| key | action | | key | action |
| -------------- | --------------------------------------------- | | -------------- | --------------------------------------------- |
| `↑` / `↓`, `j` / `k` | move through stations | | `↑` / `↓`, `j` / `k` | move through stations |
| `pgup` / `pgdn` | page through stations |
| `enter` | tune in to the selected station | | `enter` | tune in to the selected station |
| `space` | play / pause | | `space` | play / pause |
| `s` | stop | | `s` | stop |
@@ -363,8 +395,11 @@ beefy_player/
base.py RadioProvider interface + HTTP/playlist toolkit base.py RadioProvider interface + HTTP/playlist toolkit
wordmark.py the half-block font every hero is drawn with wordmark.py the half-block font every hero is drawn with
somafm/ the SomaFM catalogue (API, hero, bundled fallback list) 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) tilderadio/ the tilderadio catalogue (one station, hero, schedule)
bassdrive/ Bassdrive (Icecast status, show schedule, rolling history) 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 player.py mpv over a Unix-socket JSON IPC channel
spectrum.py analyser (parec / Core Audio tap / ffmpeg / sim) spectrum.py analyser (parec / Core Audio tap / ffmpeg / sim)
macos_tap.py macOS output capture (ctypes + Core Audio) 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 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 download; the display-title's show is matched against the running slot for
"live from <city>". "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 - `beefy_player/providers/wordmark.py` — the half-block capitals behind every
hero, with per-character style spans. hero, with per-character style spans.
- `beefy_player/widgets/schedule.py` — the left pane for single-stream - `beefy_player/widgets/schedule.py` — the left pane for single-stream
+15
View File
@@ -76,6 +76,8 @@ class BeefyPlayerApp(App[None]):
Binding("down,j", "cursor_down", "down", show=False), Binding("down,j", "cursor_down", "down", show=False),
Binding("g", "cursor_top", "top", show=False), Binding("g", "cursor_top", "top", show=False),
Binding("shift+g", "cursor_bottom", "bottom", show=False), Binding("shift+g", "cursor_bottom", "bottom", show=False),
Binding("pageup", "page_up", "page up", show=False),
Binding("pagedown", "page_down", "page down", show=False),
Binding("enter", "tune", "tune in"), Binding("enter", "tune", "tune in"),
Binding("space", "toggle_play", "play/pause"), Binding("space", "toggle_play", "play/pause"),
Binding("s", "stop", "stop", show=False), Binding("s", "stop", "stop", show=False),
@@ -824,6 +826,19 @@ class BeefyPlayerApp(App[None]):
self.index = min(len(self.view) - 1, self.index + 1) self.index = min(len(self.view) - 1, self.index + 1)
self.query_one(StationList).refresh() self.query_one(StationList).refresh()
def _page(self, delta: int) -> None:
if not self.view:
return
page = max(1, self.query_one(StationList).size.height - 1)
self.index = max(0, min(len(self.view) - 1, self.index + delta * page))
self.query_one(StationList).refresh()
def action_page_up(self) -> None:
self._page(-1)
def action_page_down(self) -> None:
self._page(1)
def action_cursor_top(self) -> None: def action_cursor_top(self) -> None:
if not self.view: if not self.view:
return return
+32 -2
View File
@@ -18,6 +18,18 @@ from __future__ import annotations
from .base import RadioProvider from .base import RadioProvider
from .bassdrive import BassdriveProvider 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 .somafm import SomaFMProvider
from .tilderadio import TilderadioProvider from .tilderadio import TilderadioProvider
@@ -29,10 +41,28 @@ __all__ = [
"get_provider", "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] = { PROVIDERS: dict[str, RadioProvider] = {
provider.id: provider 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. #: Provider used when nothing else is configured.
+2 -1
View File
@@ -194,8 +194,9 @@ class RadioProvider(ABC):
def is_playlist(url: str) -> bool: 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() 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: 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, ...]] = { GLYPHS: dict[str, tuple[str, ...]] = {
"A": (".##.", "#..#", "####", "#..#", "#..#", "#..#"), "A": (".##.", "#..#", "####", "#..#", "#..#", "#..#"),
"B": ("###.", "#..#", "###.", "#..#", "#..#", "###."), "B": ("###.", "#..#", "###.", "#..#", "#..#", "###."),
"C": (".##.", "#..#", "#...", "#...", "#..#", ".##."),
"D": ("###.", "#..#", "#..#", "#..#", "#..#", "###."), "D": ("###.", "#..#", "#..#", "#..#", "#..#", "###."),
"E": ("####", "#...", "###.", "#...", "#...", "####"), "E": ("####", "#...", "###.", "#...", "#...", "####"),
"F": ("####", "#...", "###.", "#...", "#...", "#..."), "F": ("####", "#...", "###.", "#...", "#...", "#..."),
"G": (".##.", "#..#", "#...", "#.##", "#..#", ".##."),
"H": ("#..#", "#..#", "####", "#..#", "#..#", "#..#"),
"I": ("###", ".#.", ".#.", ".#.", ".#.", "###"), "I": ("###", ".#.", ".#.", ".#.", ".#.", "###"),
"K": ("#..#", "#.#.", "##..", "##..", "#.#.", "#..#"),
"L": ("#...", "#...", "#...", "#...", "#...", "####"), "L": ("#...", "#...", "#...", "#...", "#...", "####"),
# 5 wide on purpose: the M is the odd one out in the original wordmark. # 5 wide on purpose: the M is the odd one out in the original wordmark.
"M": ("#...#", "##.##", "#.#.#", "#.#.#", "#...#", "#...#"), "M": ("#...#", "##.##", "#.#.#", "#.#.#", "#...#", "#...#"),
"N": ("#..#", "##.#", "##.#", "#.##", "#..#", "#..#"),
"O": (".##.", "#..#", "#..#", "#..#", "#..#", ".##."), "O": (".##.", "#..#", "#..#", "#..#", "#..#", ".##."),
"P": ("###.", "#..#", "###.", "#...", "#...", "#..."),
"R": ("###.", "#..#", "###.", "#.#.", "#..#", "#..#"), "R": ("###.", "#..#", "###.", "#.#.", "#..#", "#..#"),
"S": (".###", "#...", "###.", "...#", "...#", "###."), "S": (".###", "#...", "###.", "...#", "...#", "###."),
"T": ("###", ".#.", ".#.", ".#.", ".#.", ".#."), "T": ("###", ".#.", ".#.", ".#.", ".#.", ".#."),
"U": ("#..#", "#..#", "#..#", "#..#", "#..#", ".##."),
"V": ("#..#", "#..#", "#..#", "#..#", ".##.", ".##."), "V": ("#..#", "#..#", "#..#", "#..#", ".##.", ".##."),
"W": ("#...#", "#...#", "#.#.#", "#.#.#", "##.##", "#...#"),
"Y": ("#..#", "#..#", ".##.", "..#.", "..#.", "..#."),
} }
ROWS = 3 ROWS = 3
+24 -11
View File
@@ -76,9 +76,6 @@ StationList {
color: ansi_default; color: ansi_default;
width: 1fr; width: 1fr;
height: 1fr; height: 1fr;
border: round ansi_bright_black;
border-title-color: ansi_default;
padding: 0 1;
background: ansi_default; background: ansi_default;
} }
@@ -87,9 +84,6 @@ SchedulePanel {
width: 1fr; width: 1fr;
height: 1fr; height: 1fr;
display: none; display: none;
border: round ansi_bright_black;
border-title-color: ansi_default;
padding: 0 1;
background: ansi_default; background: ansi_default;
} }
@@ -97,8 +91,30 @@ StagePanel {
color: ansi_default; color: ansi_default;
layout: vertical; layout: vertical;
width: 1fr; width: 1fr;
border: round ansi_bright_black; background: ansi_default;
border-title-color: ansi_default; }
#stage_frame_top,
#stage_frame_bottom {
height: 1;
background: ansi_default;
}
#stage_middle {
height: 1fr;
background: ansi_default;
}
#stage_frame_left,
#stage_frame_right {
width: 1;
height: 1fr;
background: ansi_default;
}
#stage_body {
width: 1fr;
height: 1fr;
padding: 0 1; padding: 0 1;
background: ansi_default; background: ansi_default;
} }
@@ -143,9 +159,6 @@ SpectrumPanel {
color: ansi_default; color: ansi_default;
height: 2fr; height: 2fr;
min-height: 6; min-height: 6;
border: round ansi_bright_black;
border-title-color: ansi_default;
padding: 0 1;
background: ansi_default; background: ansi_default;
} }
+163
View File
@@ -0,0 +1,163 @@
"""Pane frames: one grey box, with two corners fading out of it.
The panes draw their own frames instead of using Textual's ``border`` so the
corners can be coloured individually. Two of them — the top right and the
bottom left — run white → light grey → bright cyan → cyan along both arms;
everything else, including the other two corners, is the border grey.
"""
from __future__ import annotations
from rich.cells import cell_len
from rich.console import Group, RenderableType
from rich.text import Text
from textual.widget import Widget
#: The frame itself: one grey, the same grey as the scrollbar thumb.
EDGE = "bright_black"
#: A faded corner runs white → light grey → bright cyan → cyan, then settles
#: back into the border grey.
CORNER_GRADIENT = ("bright_white", "white", "bright_cyan", "cyan")
def fade(distance: int) -> str:
"""Colour ``distance`` cells away from a corner (0 is the corner itself)."""
if 0 <= distance < len(CORNER_GRADIENT):
return CORNER_GRADIENT[distance]
return EDGE
def side_style(row: int, rows: int, side: str = "left") -> str:
"""Colour of a side cell: only two corners fade, so only one end glows."""
if side == "right":
return fade(row + 1) # fades down from the top-right corner
return fade(rows - row) # fades up from the bottom-left corner
def inner_size(width: int, height: int) -> tuple[int, int]:
"""The content box inside the frame."""
return max(1, width - 2), max(1, height - 2)
def _edge_cells(width: int, faded_end: str = "right") -> list[tuple[str, str]]:
"""Inner cells of an edge; only one end of it fades into a corner."""
inner = max(0, width - 2)
if faded_end == "left":
return [("─", fade(column + 1)) for column in range(inner)]
return [("─", fade(inner - column)) for column in range(inner)]
def _paint(line: Text, cells: list[tuple[str, str]]) -> None:
"""Append cells, grouping neighbours that share a style into one span."""
style: str | None = None
run: list[str] = []
for char, cell_style in cells:
if cell_style != style:
if run:
line.append("".join(run), style=style)
run, style = [], cell_style
run.append(char)
if run:
line.append("".join(run), style=style)
def top(width: int, title: str = "") -> Text:
width = max(2, width)
cells = _edge_cells(width, "right") # only the top-right corner fades
if title:
label = f" {title} "
start = 1
if start + cell_len(label) <= len(cells):
for offset, char in enumerate(label):
cells[start + offset] = (char, "bold")
line = Text(no_wrap=True)
line.append("╭", style=EDGE)
_paint(line, cells)
line.append("╮", style=f"bold {fade(0)}")
return line
def bottom(width: int) -> Text:
width = max(2, width)
line = Text(no_wrap=True)
line.append("╰", style=f"bold {fade(0)}")
_paint(line, _edge_cells(width, "left")) # only the bottom-left fades
line.append("╯", style=EDGE)
return line
def side() -> Text:
return Text("│", style=EDGE)
def side_row(row: int, rows: int, side: str = "left") -> Text:
return Text("│", style=side_style(row, rows, side))
def wrap(
lines: list[Text], width: int, height: int, title: str = ""
) -> RenderableType:
"""Frame ``lines`` (already sized for the content box) in a pane."""
width = max(4, width)
height = max(2, height)
inner_width, inner_height = inner_size(width, height)
rows = len(lines[:inner_height])
framed: list[Text] = [top(width, title)]
for index, line in enumerate(lines[:inner_height]):
row = Text(no_wrap=True)
row.append_text(side_row(index, rows, "left"))
row.append_text(line)
pad = inner_width - cell_len(line.plain)
if pad > 0:
row.append(" " * pad)
row.append_text(side_row(index, rows, "right"))
framed.append(row)
while len(framed) < height - 1:
index = len(framed) - 1
row = Text(no_wrap=True)
row.append_text(side_row(index, inner_height, "left"))
row.append(" " * inner_width)
row.append_text(side_row(index, inner_height, "right"))
framed.append(row)
framed.append(bottom(width))
return Group(*framed[:height])
# --------------------------------------------------------------------------- #
# frame pieces, for containers that cannot draw their own content
# --------------------------------------------------------------------------- #
class FrameTop(Widget):
can_focus = False
def __init__(self, title: str = "", *, id: str | None = None) -> None:
super().__init__(id=id)
self.title = title
def render(self) -> RenderableType:
return top(max(2, self.size.width), self.title)
class FrameBottom(Widget):
can_focus = False
def render(self) -> RenderableType:
return bottom(max(2, self.size.width))
class FrameSide(Widget):
can_focus = False
def __init__(self, side: str = "left", *, id: str | None = None) -> None:
super().__init__(id=id)
self.side = side
def render(self) -> RenderableType:
rows = max(1, self.size.height)
return Group(
*[side_row(row, rows, self.side) for row in range(rows)]
)
+5
View File
@@ -74,6 +74,11 @@ class HeaderBar(Widget):
lines: list[Text] = [] lines: list[Text] = []
for index, row in enumerate(hero): for index, row in enumerate(hero):
line = _hero_line(row, index, hero_width, gradient_fill) line = _hero_line(row, index, hero_width, gradient_fill)
# Art rows are right-trimmed, so a row ending in a blank cell is a
# column short; pad it back or the caption and clock drift left.
short = hero_width - cell_len(line.plain)
if short > 0:
line.append(" " * short)
if show_middle and text_offset <= index < text_offset + len(hero_lines): if show_middle and text_offset <= index < text_offset + len(hero_lines):
middle = hero_lines[index - text_offset] middle = hero_lines[index - text_offset]
line.append(" " * 4) line.append(" " * 4)
+1
View File
@@ -21,6 +21,7 @@ if TYPE_CHECKING:
HELP_ROWS: tuple[tuple[str, str], ...] = ( HELP_ROWS: tuple[tuple[str, str], ...] = (
("↑ / ↓ j / k", "move through the station list"), ("↑ / ↓ j / k", "move through the station list"),
("pgup / pgdn", "page through the station list"),
("enter", "tune in to the selected station"), ("enter", "tune in to the selected station"),
("space", "play / pause the current station"), ("space", "play / pause the current station"),
("s", "stop playback"), ("s", "stop playback"),
+30 -11
View File
@@ -30,24 +30,43 @@ class ProviderScreen(ModalScreen[str | None]):
header.append("CATALOGUES", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}") header.append("CATALOGUES", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}")
header.append(" · pick a station catalogue", style="dim") header.append(" · pick a station catalogue", style="dim")
name_width = (
max(
(
len(PROVIDERS[slug].name)
for slug in self.slugs
if slug in PROVIDERS
),
default=12,
)
+ 2
)
rows: list[Text] = [] rows: list[Text] = []
for index, slug in enumerate(self.slugs): for index, slug in enumerate(self.slugs):
provider = PROVIDERS[slug] provider = PROVIDERS[slug]
selected = index == self.index selected = index == self.index
row = Text(no_wrap=True) row = Text(no_wrap=True)
row.append("▶ " if selected else " ", style="bold magenta" if selected else "") row.append(
row.append(f"{index + 1} ", style="dim") "▶ " if selected else " ", style="bold magenta" if selected else ""
name_style = "bold underline" if selected else "bold" )
row.append(f"{provider.name:<12}", style=name_style) row.append(f"{index + 1:>2} ", style="dim")
row.append(f" {provider.brand}", style="dim") row.append(
f"{truncate(provider.name, name_width - 2):<{name_width}}",
style="bold underline" if selected else "bold",
)
row.append(truncate(provider.brand, 14), style="dim")
if slug == self.current_id: if slug == self.current_id:
row.append(" ●", style="bold green") row.append(" ●", style="bold green")
rows.append(row) rows.append(row)
if selected:
detail = Text(no_wrap=True) rows.append(
detail.append(" ", style="") Text(
detail.append(truncate(provider.tagline, 54), style="italic dim") " " + truncate(provider.tagline, 54),
rows.append(detail) style="italic dim",
no_wrap=True,
)
)
current = PROVIDERS.get(self.current_id) current = PROVIDERS.get(self.current_id)
footer = Text() footer = Text()
@@ -57,7 +76,7 @@ class ProviderScreen(ModalScreen[str | None]):
f"now: {current.name} — {current.station_source}\n", style="dim" f"now: {current.name} — {current.station_source}\n", style="dim"
) )
footer.append( footer.append(
"↑ ↓ choose · 1–9 jump · enter switch · esc cancel", "↑ ↓ choose · enter switch · esc cancel",
style="dim", style="dim",
) )
return Group(header, Text(""), *rows, footer) return Group(header, Text(""), *rows, footer)
+10 -3
View File
@@ -11,10 +11,11 @@ from __future__ import annotations
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, cast
from rich.console import Group, RenderableType from rich.console import RenderableType
from rich.text import Text from rich.text import Text
from textual.widget import Widget from textual.widget import Widget
from . import frame
from .common import local_time, local_zone, state_glyph, truncate from .common import local_time, local_zone, state_glyph, truncate
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -92,7 +93,8 @@ class SchedulePanel(Widget):
def render(self) -> RenderableType: def render(self) -> RenderableType:
app = cast("BeefyPlayerApp", self.app) app = cast("BeefyPlayerApp", self.app)
width = max(12, self.size.width) width = max(12, self.size.width)
height = max(1, self.size.height) height = max(3, self.size.height)
width, height = frame.inner_size(width, height)
broadcasts = list(getattr(app, "schedule", ()) or ()) broadcasts = list(getattr(app, "schedule", ()) or ())
loaded = bool(getattr(app, "schedule_loaded", False)) loaded = bool(getattr(app, "schedule_loaded", False))
@@ -134,4 +136,9 @@ class SchedulePanel(Widget):
lines.append(Text("")) lines.append(Text(""))
while len(lines) < height: while len(lines) < height:
lines.append(Text("")) lines.append(Text(""))
return Group(*lines[:height]) return frame.wrap(
lines[:height],
max(12, self.size.width),
max(3, self.size.height),
self.border_title,
)
+16 -13
View File
@@ -7,11 +7,12 @@ from collections import deque
from collections.abc import Sequence from collections.abc import Sequence
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, cast
from rich.console import Group, RenderableType from rich.console import RenderableType
from rich.text import Text from rich.text import Text
from textual.widget import Widget from textual.widget import Widget
from ..spectrum import Frame, SpectrumEngine from ..spectrum import Frame, SpectrumEngine
from . import frame
from .common import LEVELS, SPECTRUM_STOPS, gradient from .common import LEVELS, SPECTRUM_STOPS, gradient
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -63,38 +64,40 @@ class SpectrumPanel(Widget):
def render(self) -> RenderableType: def render(self) -> RenderableType:
app = cast("BeefyPlayerApp", self.app) app = cast("BeefyPlayerApp", self.app)
frame = self.engine.snapshot() snapshot = self.engine.snapshot()
width = max(8, self.size.width) outer_width, outer_height = max(12, self.size.width), max(4, self.size.height)
height = max(2, self.size.height) width, height = frame.inner_size(outer_width, outer_height)
mode = getattr(app, "visualizer", "both") mode = getattr(app, "visualizer", "both")
phase = int(getattr(app, "tick_count", 0) // 2) phase = int(getattr(app, "tick_count", 0) // 2)
dim = frame.status == "idle" dim = snapshot.status == "idle"
self._record(frame, int(getattr(app, "tick_count", 0))) self._record(snapshot, int(getattr(app, "tick_count", 0)))
lines: list[Text] lines: list[Text]
if mode == "wave": if mode == "wave":
lines = self._wave(frame, width, height, phase, dim) lines = self._wave(snapshot, width, height, phase, dim)
elif mode == "braille": elif mode == "braille":
lines = self._braille(frame, width, height, phase, dim) lines = self._braille(snapshot, width, height, phase, dim)
elif mode == "waterfall": elif mode == "waterfall":
lines = self._waterfall(width, height, dim) lines = self._waterfall(width, height, dim)
elif mode == "radial": elif mode == "radial":
lines = self._radial(frame, width, height, phase, dim) lines = self._radial(snapshot, width, height, phase, dim)
elif mode == "bars" or height < 8: elif mode == "bars" or height < 8:
lines = self._bars(frame, width, height, phase, dim) lines = self._bars(snapshot, width, height, phase, dim)
else: else:
# Default mode: a full-height mirrored spectrum. The top half # Default mode: a full-height mirrored spectrum. The top half
# grows up from the centre line, the bottom half grows down from # grows up from the centre line, the bottom half grows down from
# it (same values, flipped rows, dimmed like a reflection). # it (same values, flipped rows, dimmed like a reflection).
top_height = height // 2 top_height = height // 2
bottom_height = height - top_height bottom_height = height - top_height
lines = self._bars(frame, width, top_height, phase, dim) lines = self._bars(snapshot, width, top_height, phase, dim)
mirrored = self._bars( mirrored = self._bars(
frame, width, bottom_height, phase + 5, True, peaks=False snapshot, width, bottom_height, phase + 5, True, peaks=False
) )
mirrored.reverse() mirrored.reverse()
lines.extend(mirrored) lines.extend(mirrored)
return Group(*lines) return frame.wrap(
lines, outer_width, outer_height, self.border_title
)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# history # history
+17 -6
View File
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, cast
from rich.console import Group, RenderableType from rich.console import Group, RenderableType
from rich.text import Text from rich.text import Text
from textual.containers import Horizontal from textual.containers import Horizontal, Vertical
from textual.css.query import NoMatches from textual.css.query import NoMatches
from textual.widget import Widget from textual.widget import Widget
@@ -21,6 +21,7 @@ from .common import (
time_ago, time_ago,
truncate, truncate,
) )
from .frame import FrameBottom, FrameSide, FrameTop
if TYPE_CHECKING: if TYPE_CHECKING:
from ..app import BeefyPlayerApp from ..app import BeefyPlayerApp
@@ -36,10 +37,18 @@ class StagePanel(Widget):
self.border_title = "ON AIR" self.border_title = "ON AIR"
def compose(self): def compose(self):
with Horizontal(id="stage_top"): # A container cannot paint its own content, so the frame is made of
yield ArtPane() # thin widgets: top, bottom, and a column either side of the body.
yield StageInfo() yield FrameTop("ON AIR", id="stage_frame_top")
yield HistoryPane() with Horizontal(id="stage_middle"):
yield FrameSide("left", id="stage_frame_left")
with Vertical(id="stage_body"):
with Horizontal(id="stage_top"):
yield ArtPane()
yield StageInfo()
yield HistoryPane()
yield FrameSide("right", id="stage_frame_right")
yield FrameBottom(id="stage_frame_bottom")
def on_mount(self) -> None: def on_mount(self) -> None:
self.refresh_art() self.refresh_art()
@@ -150,7 +159,9 @@ class StageInfo(Widget):
if song.art_url and album.plain.strip(): if song.art_url and album.plain.strip():
album.append(" ▣", style="cyan") album.append(" ▣", style="cyan")
else: else:
artist = Text("waiting for track data…", style="italic yellow") # No track metadata from this catalogue: keep the line, leave it
# blank rather than filling it with an apology.
artist = Text("")
fallback = station.last_playing or "tuning in" fallback = station.last_playing or "tuning in"
title = Text(marquee(fallback, width), style="bold") title = Text(marquee(fallback, width), style="bold")
album = Text(truncate(station.genre_label, width), style="dim") album = Text(truncate(station.genre_label, width), style="dim")
+82 -18
View File
@@ -1,19 +1,23 @@
"""Station list with favourites, live markers and a filter.""" """Station list with favourites, live markers, a filter and a scrollbar."""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, cast
from rich.cells import cell_len from rich.cells import cell_len
from rich.console import Group, RenderableType from rich.console import RenderableType
from rich.text import Text from rich.text import Text
from textual.widget import Widget from textual.widget import Widget
from . import frame
from .common import compact_count, truncate from .common import compact_count, truncate
if TYPE_CHECKING: if TYPE_CHECKING:
from ..app import BeefyPlayerApp from ..app import BeefyPlayerApp
#: Columns the scrollbar occupies (a gap plus the bar itself).
GUTTER = 2
class StationList(Widget): class StationList(Widget):
can_focus = False can_focus = False
@@ -22,34 +26,89 @@ class StationList(Widget):
super().__init__() super().__init__()
self.border_title = "STATIONS" self.border_title = "STATIONS"
# -- title: filter + position --------------------------------------- #
def set_filter(self, query: str) -> None: def set_filter(self, query: str) -> None:
self._sync_title(query)
def _sync_title(self, query: str | None = None) -> None:
"""``STATIONS · /jazz · 3/12`` — the filter and where you are in it."""
if query is None:
query = ""
total = 0
index = 0
if self.is_mounted:
app = cast("BeefyPlayerApp", self.app)
query = app.query
total = len(app.view)
index = app.index
safe_query = query.replace("[", "(").replace("]", ")") if query else "" safe_query = query.replace("[", "(").replace("]", ")") if query else ""
title = f"STATIONS · /{safe_query}" if safe_query else "STATIONS" parts = ["STATIONS"]
if safe_query:
parts.append(f"/{safe_query}")
if total > 1:
parts.append(f"{min(index + 1, total)}/{total}")
title = " · ".join(parts)
if self.border_title != title: if self.border_title != title:
self.border_title = title self.border_title = title
def refresh(self, *args, **kwargs): # type: ignore[override]
# Every cursor move ends in a refresh, so this is the one place that
# has to keep the title's position readout honest.
if self.is_mounted:
self._sync_title()
return super().refresh(*args, **kwargs)
def on_mouse_scroll_down(self, event) -> None: def on_mouse_scroll_down(self, event) -> None:
cast("BeefyPlayerApp", self.app).action_cursor_down() cast("BeefyPlayerApp", self.app).action_cursor_down()
def on_mouse_scroll_up(self, event) -> None: def on_mouse_scroll_up(self, event) -> None:
cast("BeefyPlayerApp", self.app).action_cursor_up() cast("BeefyPlayerApp", self.app).action_cursor_up()
# -- scrollbar geometry --------------------------------------------- #
@staticmethod
def thumb(height: int, total: int, first: int) -> set[int]:
"""Rows of the pane the scrollbar thumb covers."""
if total <= height:
return set(range(height))
size = max(1, round(height * height / total))
span = max(0, total - height)
top = 0 if span == 0 else round(first * (height - size) / span)
return set(range(top, min(height, top + size)))
def render(self) -> RenderableType: def render(self) -> RenderableType:
app = cast("BeefyPlayerApp", self.app) app = cast("BeefyPlayerApp", self.app)
width = max(12, self.size.width) width = max(12, self.size.width)
height = max(1, self.size.height) height = max(3, self.size.height)
inner_width, inner_height = frame.inner_size(width, height)
view = app.view view = app.view
if not view: if not view:
message = "no stations match" if app.query else "loading stations…" message = "no stations match" if app.query else "loading stations…"
return Group(Text(truncate(message, width), style="dim italic")) return frame.wrap(
[Text(truncate(message, inner_width), style="dim italic")],
width,
height,
self.border_title,
)
total = len(view)
first = 0 first = 0
if len(view) > height: if total > inner_height:
first = max(0, min(app.index - height // 2, len(view) - height)) first = max(0, min(app.index - inner_height // 2, total - inner_height))
scrollable = total > inner_height
gutter = GUTTER if scrollable else 0
content = max(4, inner_width - gutter)
thumb = (
self.thumb(inner_height, total, first) if scrollable else set()
)
lines: list[Text] = [] lines: list[Text] = []
for offset in range(min(height, len(view))): for offset in range(min(inner_height, total)):
index = first + offset index = first + offset
station = view[index] station = view[index]
selected = index == app.index selected = index == app.index
@@ -64,32 +123,29 @@ class StationList(Widget):
count_text = "" count_text = ""
count_width = 0 count_width = 0
if width >= 26 and station.listeners > 0: if content >= 26 and station.listeners > 0:
count_text = f"{compact_count(station.listeners)} ♪" count_text = f"{compact_count(station.listeners)} ♪"
count_width = cell_len(count_text) + 1 count_width = cell_len(count_text) + 1
prefix = marker + star + play + " " prefix = marker + star + play + " "
available = width - cell_len(prefix) - count_width available = content - cell_len(prefix) - count_width
title = truncate(station.title, max(4, available)) title = truncate(station.title, max(4, available))
gap = max(0, width - cell_len(prefix) - cell_len(title) - count_width) gap = max(0, content - cell_len(prefix) - cell_len(title) - count_width)
if selected: if selected:
base = ""
marker_style = "magenta" marker_style = "magenta"
title_style = "bold underline" title_style = "bold underline"
count_style = "bold" count_style = "bold"
elif current: elif current:
base = ""
marker_style = "cyan" marker_style = "cyan"
title_style = "cyan" title_style = "cyan"
count_style = "dim" count_style = "dim"
else: else:
base = ""
marker_style = "" marker_style = ""
title_style = "" title_style = ""
count_style = "dim" count_style = "dim"
row = Text(no_wrap=True, style=base) row = Text(no_wrap=True)
row.append(marker, style=marker_style) row.append(marker, style=marker_style)
row.append(star, style="bold yellow" if station.favorite else "") row.append(star, style="bold yellow" if station.favorite else "")
row.append(play, style="cyan" if playing else "") row.append(play, style="cyan" if playing else "")
@@ -99,8 +155,16 @@ class StationList(Widget):
if count_text: if count_text:
row.append(count_text, style=count_style) row.append(count_text, style=count_style)
used = cell_len(prefix) + cell_len(title) + gap + cell_len(count_text) used = cell_len(prefix) + cell_len(title) + gap + cell_len(count_text)
if used < width: if used < content:
row.append(" " * (width - used)) row.append(" " * (content - used))
if scrollable:
# Just the thumb, in the frame's own grey: no rail.
row.append(" ")
row.append(
"▐" if offset in thumb else " ",
style=frame.EDGE,
)
lines.append(row) lines.append(row)
return Group(*lines) return frame.wrap(lines, width, height, self.border_title)
+2 -2
View File
@@ -1,13 +1,13 @@
[project] [project]
name = "beefy-player" name = "beefy-player"
version = "0.1.0" 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" readme = "README.md"
license = "WTFPL" license = "WTFPL"
license-files = ["LICENSE"] license-files = ["LICENSE"]
requires-python = ">=3.10" requires-python = ">=3.10"
authors = [{ name = "beefy-player contributors" }] 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 = [ classifiers = [
"Environment :: Console", "Environment :: Console",
"Intended Audience :: End Users/Desktop", "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_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://ice1.somafm.com/x-128-mp3") is True
assert base.is_direct_stream("https://x/y.m3u") is False 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: 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",
}
+81
View File
@@ -0,0 +1,81 @@
"""The BBS frames: grey edges, corners fading white → grey → cyan."""
from __future__ import annotations
from rich.text import Text
from beefy_player.widgets import frame
def styles_of(line: Text) -> list[tuple[str, str]]:
return [
(line.plain[span.start : span.end], str(span.style)) for span in line.spans
]
def test_only_the_top_right_corner_fades_on_the_top_edge() -> None:
spans = styles_of(frame.top(30))
assert spans[0] == ("╭", "bright_black") # plain grey corner
# the fade runs into the top-right corner: cyan, bright cyan, light grey
assert [style for _text, style in spans[-5:-1]] == [
"bright_black",
"cyan",
"bright_cyan",
"white",
]
assert spans[-1] == ("╮", "bold bright_white")
def test_only_the_bottom_left_corner_fades_on_the_bottom_edge() -> None:
spans = styles_of(frame.bottom(30))
assert spans[0] == ("╰", "bold bright_white")
assert [style for _text, style in spans[1:4]] == ["white", "bright_cyan", "cyan"]
assert spans[-1] == ("╯", "bright_black") # plain grey corner
def test_the_sides_glow_towards_their_own_corner_only() -> None:
# left column fades up from the bottom-left corner
assert [frame.side_style(row, 10, "left") for row in range(10)] == [
"bright_black",
"bright_black",
"bright_black",
"bright_black",
"bright_black",
"bright_black",
"bright_black",
"cyan",
"bright_cyan",
"white",
]
# right column fades down from the top-right corner
assert [frame.side_style(row, 10, "right") for row in range(10)] == [
"white",
"bright_cyan",
"cyan",
"bright_black",
"bright_black",
"bright_black",
"bright_black",
"bright_black",
"bright_black",
"bright_black",
]
def test_the_title_sits_after_the_left_fade() -> None:
line = frame.top(30, "STATIONS")
assert line.plain.startswith("╭─ STATIONS ─")
assert "STATIONS" in line.plain
# a pane too narrow for the title drops it rather than truncating
assert "STATIONS" not in frame.top(10, "STATIONS").plain
def test_wrap_frames_the_content_on_every_row() -> None:
content = [Text("hello"), Text("world")]
group = frame.wrap(content, 20, 6, "PANE")
lines = [line.plain for line in group.renderables]
assert len(lines) == 6
assert lines[0].startswith("╭") and lines[-1].startswith("╰")
assert all(len(line) == 20 for line in lines)
assert all(line.startswith("│") and line.endswith("│") for line in lines[1:-1])
assert lines[1].startswith("│hello")
+16
View File
@@ -86,6 +86,22 @@ def test_the_font_still_spells_soma_the_way_the_original_logo_did() -> None:
) )
@pytest.mark.parametrize("slug", sorted(PROVIDERS))
def test_the_caption_block_starts_on_one_column(slug: str) -> None:
"""Art rows are right-trimmed; a row ending in a blank cell must not pull
its caption a column to the left."""
provider = PROVIDERS[slug]
captions = provider.hero_lines
offset = max(0, (len(provider.hero) - len(captions)) // 2)
lines = render_header(200, height=len(provider.hero), provider=provider)
lines = lines[offset : offset + len(captions)]
columns = {
line.index(caption)
for line, caption in zip(lines, captions, strict=True)
}
assert len(columns) == 1, (slug, columns)
def test_every_catalogue_has_a_three_row_wordmark() -> None: def test_every_catalogue_has_a_three_row_wordmark() -> None:
for slug, provider in PROVIDERS.items(): for slug, provider in PROVIDERS.items():
assert len(provider.hero) == 3, slug assert len(provider.hero) == 3, slug
+23 -2
View File
@@ -22,7 +22,22 @@ def rendered_text(app) -> str:
def test_registry_lists_somafm_as_the_default() -> None: 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 isinstance(get_provider(), SomaFMProvider)
assert get_provider("somafm") is get_provider() assert get_provider("somafm") is get_provider()
assert isinstance(get_provider("tilderadio"), TilderadioProvider) assert isinstance(get_provider("tilderadio"), TilderadioProvider)
@@ -177,7 +192,13 @@ async def test_p_opens_the_picker_and_switches_catalogue() -> None:
await pilot.pause() await pilot.pause()
assert isinstance(app.screen, ProviderScreen) 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()
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") == []
+104
View File
@@ -0,0 +1,104 @@
"""The station pane: paging, the position readout and the scrollbar."""
from __future__ import annotations
import re
import pytest
from beefy_player.app import BeefyPlayerApp
from beefy_player.widgets.station_list import StationList
def app_for(provider: str, *, offline: bool = True) -> BeefyPlayerApp:
return BeefyPlayerApp(provider=provider, autoplay=False, offline=offline, audio=False)
def test_thumb_geometry() -> None:
# nothing to scroll: the whole track is the thumb
assert StationList.thumb(10, 10, 0) == set(range(10))
# top of a long list
top = StationList.thumb(10, 100, 0)
assert 0 in top and len(top) >= 1
# bottom of the same list ends at the last row
bottom = StationList.thumb(10, 100, 90)
assert 9 in bottom
# mid-list sits between the two
middle = StationList.thumb(10, 100, 45)
assert min(middle) > min(top) and max(middle) < max(bottom)
@pytest.mark.asyncio
async def test_paging_moves_a_screen_and_back() -> None:
app = app_for("somafm")
async with app.run_test(size=(100, 26)) as pilot:
await pilot.pause()
pane = app.query_one(StationList)
page = pane.size.height - 1
start = app.index
await pilot.press("pagedown")
await pilot.pause()
assert app.index == min(start + page, len(app.view) - 1)
await pilot.press("pageup")
await pilot.pause()
assert app.index == start
# the bottom of the list clamps instead of wrapping
await pilot.press("shift+g", "pagedown")
await pilot.pause()
assert app.index == len(app.view) - 1
@pytest.mark.asyncio
async def test_the_title_reads_out_the_position() -> None:
app = app_for("somafm")
async with app.run_test(size=(100, 26)) as pilot:
await pilot.pause()
pane = app.query_one(StationList)
assert re.fullmatch(r"STATIONS · \d+/\d+", pane.border_title)
await pilot.press("down")
await pilot.pause()
first, total = pane.border_title.split(" · ")[1].split("/")
assert int(total) == len(app.view)
assert int(first) == app.index + 1
@pytest.mark.asyncio
async def test_the_scrollbar_appears_only_when_the_list_overflows() -> None:
app = app_for("somafm")
async with app.run_test(size=(100, 26)) as pilot:
await pilot.pause()
pane = app.query_one(StationList)
rows = [line.plain for line in pane.render().renderables]
assert rows[0].startswith("╭") and rows[-1].startswith("╰")
content = rows[1:-1]
# thumb only, in the frame grey: blank cells elsewhere, no rail
assert all(row[-2] in "▐ " for row in content)
assert any(row[-2] == "▐" for row in content)
app = app_for("rinse") # three stations, no scrolling
async with app.run_test(size=(100, 26)) as pilot:
await pilot.pause()
pane = app.query_one(StationList)
rows = [line.plain for line in pane.render().renderables]
assert rows[0].startswith("╭") and rows[-1].startswith("╰")
assert sum("Rinse" in row or "Kool" in row or "SWU" in row for row in rows) == 3
# three stations fit: no thumb in the content rows
assert not any(row[-2] == "▐" for row in rows[1:-1])
@pytest.mark.asyncio
async def test_the_filter_keeps_the_readout_in_step() -> None:
app = app_for("somafm")
async with app.run_test(size=(100, 26)) as pilot:
await pilot.pause()
await pilot.press("slash")
await pilot.pause()
app.query_one("SearchInput").value = "drone"
await pilot.pause()
pane = app.query_one(StationList)
assert pane.border_title.startswith("STATIONS · /drone · ")
filtered = len(app.view)
assert pane.border_title.endswith(f"/{filtered}")
+7 -2
View File
@@ -52,8 +52,13 @@ async def test_backgrounds_use_terminal_default_and_borders_are_ansi() -> None:
assert widget.styles.background.ansi == -1, widget_type assert widget.styles.background.ansi == -1, widget_type
assert widget.styles.color.ansi == -1, widget_type assert widget.styles.color.ansi == -1, widget_type
border = app.query_one(StationList).styles.border.top # panes draw their own frames now: dim edges, gradient corners
assert border[1].ansi is not None # an ANSI palette index, not RGB pane = app.query_one(StationList)
assert pane.styles.border.top[0] == "" # no framework border any more
framed = pane.render().renderables
corners = [str(span.style) for span in framed[0].spans]
assert framed[0].plain.startswith("╭")
assert any(style.strip() for style in corners)
await pilot.press("slash") await pilot.press("slash")
await pilot.pause() await pilot.pause()