feat(bassdrive): add the drum & bass relay and a shared wordmark font

This commit is contained in:
beefcarver committed 2026-11-03 18:44:40 -05:00
1 parent 84ec3b08f6
commit 50a9a735f7
14 files changed
+1166 -79

No files matched your search

+53 -26
View File
@@ -8,9 +8,10 @@ own ANSI palette on the terminal's default background so it fits any theme
(including transparent terminals).
The station catalogue is pluggable. Beefy Player ships with
**[SomaFM](https://somafm.com)** (≈50 curated channels) and
**[SomaFM](https://somafm.com)** (≈50 curated channels),
**[tilderadio](https://tilderadio.org)** (the tildeverse's single community
stream), and the UI never talks to a station catalogue directly: it asks a
stream) and **[Bassdrive](https://bassdrive.com)** (worldwide drum & bass), and
the UI never talks to a station catalogue directly: it asks a
[provider](#providers) for stations, song history and stream URLs. Press `p` to
switch catalogue without restarting. Adding another internet-radio catalogue is
a self-contained module, not a rewrite.
@@ -41,7 +42,9 @@ implemented here from public endpoints and open protocols.
iTerm2 inline-image protocol. Images occupy exactly the ON AIR art cell box
and fall back to the procedural ANSI mosaic for other terminals, failed
loads, or blocked graphics passthrough. Images are cached in
`~/.cache/beefy-player/artwork`.
`~/.cache/beefy-player/artwork`, letterboxed rather than cropped, and an
image that carries an alpha channel is composited onto black — a transparent
PNG drawn over the mosaic reads as noise behind a station logo.
- **Now playing** — artist, title, album, DJ, listeners, format/bitrate and
"played N minutes ago", with procedural ANSI-shaded album art that changes
per track. When the current track can be found on Bandcamp, a clickable
@@ -57,9 +60,10 @@ implemented here from public endpoints and open protocols.
- **Pluggable catalogues** — one `providers` module per radio network, with a
`p` picker in the UI, `--provider` on the command line, and the choice
remembered per catalogue (including the last station you tuned).
- **A hero per catalogue** — the top-left art comes from the provider: SomaFM's
block wordmark, or tilderadio's radio icon converted from its logo PNG to
half-block ANSI by `tools/ansi_art.py`.
- **A hero per catalogue** — the top-left art is the catalogue's own name in a
shared half-block font: SomaFM in the spectrum gradient, tilderadio in the
terminal's default colour, Bassdrive in its two blues. Every hero is three
rows tall, so the header keeps one height.
- **Schedule pane** — a single-stream catalogue gets a broadcast schedule
instead of a list of one station: who is on air now, since/until when, and
the upcoming shows grouped by day.
@@ -193,35 +197,45 @@ layout and the guessed icecast mirrors all live in
`beefy_player/providers/somafm/`. Nothing outside that package imports it; the
app only ever holds a `RadioProvider`.
Two catalogues ship today:
Three catalogues ship today:
| slug | catalogue | station list | history | streams |
| --- | --- | --- | --- | --- |
| `somafm` | [SomaFM](https://somafm.com) | `channels.json` (≈50) | `songs/<station>.json` | `.pls` → icecast mirrors |
| `tilderadio` | [tilderadio](https://tilderadio.org) | `/api/now/` (1) | `now_playing` + `history[]` | AzuraCast mounts (mp3/ogg, 320k) |
| `bassdrive` | [Bassdrive](https://bassdrive.com) | icecast status (1) | rolling `display-title` | three mounts + mirrors |
They are deliberately different shapes: one has fifty channels and PLS
playlists, the other has a single stream whose whole catalogue arrives in the
same response as its history. That is the seam working.
They are deliberately different shapes: fifty channels with PLS playlists, a
single AzuraCast stream that carries its own history, and an Icecast relay whose
"now playing" is the *show* rather than the track. That is the seam working.
The tilderadio package also exercises the two optional capabilities: its left
pane is the broadcast schedule (`/api/schedule/`, with the live DJ coming from
`/api/now/`) rather than a list containing one station, and its header art is
the station's own radio icon converted to half blocks. The icon is generated,
not hand-drawn — regenerate it after the logo changes:
The single-stream catalogues also use the optional capabilities: their left
pane is the broadcast schedule rather than a list containing one station, and
they opt out of Bandcamp lookups. Bassdrive is the extreme case — Icecast
reports one title and keeps no history, so the provider maps the title to
`Station.dj`/`presenter` and keeps a rolling record of the shows it has heard.
```bash
uv run python tools/ansi_art.py \
--url https://tilderadio.org/logos/tilderadio.png \
--cols 22 --rows 7 \
--module beefy_player/providers/tilderadio/hero.py
### Header wordmarks
All three heroes come from one font, `beefy_player/providers/wordmark.py`:
four-column capitals six pixels tall, emitted as three rows of half blocks
(`▀`/`▄`/`█`) — the shape of the original SomaFM logo. A provider's `hero.py`
is one line of data:
```python
# beefy_player/providers/bassdrive/hero.py
HERO = render("BASSDRIVE", ["bright_cyan"] * 4 + ["cyan"] * 5)
```
`tools/ansi_art.py` crops the transparent margin, resizes to 22×14 pixels (two
per text cell), quantises to the stock 16-colour ANSI palette and emits
run-length encoded rich markup — so the art follows the terminal's palette
instead of pinning RGB values. `--preview out.png` draws the result, which is
far easier to judge than ▀ and ▄.
`render()` takes one optional style per character and groups equal neighbours
into markup spans; with no styles the wordmark inherits the terminal's default
foreground (tilderadio). Add a glyph to `GLYPHS` when a catalogue needs a letter
the font does not have yet — `▀█▀` is the I, one pixel thick on purpose.
Image logos are a different problem: `tools/ansi_art.py` converts a PNG into
half-block art (`--key-white` for logos on white, `--preview out.png` to see the
result). No shipped catalogue needs it today, but it is the tool to reach for
when a station's identity is a picture rather than a word.
Adding a catalogue:
@@ -347,8 +361,10 @@ beefy_player/
config.py XDG paths, settings, BEEFY_* env helpers
providers/
base.py RadioProvider interface + HTTP/playlist toolkit
wordmark.py the half-block font every hero is drawn with
somafm/ the SomaFM catalogue (API, hero, bundled fallback list)
tilderadio/ the tilderadio catalogue (one station, hero, schedule)
bassdrive/ Bassdrive (Icecast status, show schedule, rolling history)
player.py mpv over a Unix-socket JSON IPC channel
spectrum.py analyser (parec / Core Audio tap / ffmpeg / sim)
macos_tap.py macOS output capture (ctypes + Core Audio)
@@ -382,6 +398,14 @@ tools/
the provider in place: the old stream is stopped, workers for the previous
catalogue are cancelled, the hero height and left pane follow the new
provider, and the station list (or schedule) reloads from cache first.
- `beefy_player/providers/bassdrive/api.py` — Icecast's keyless
`status-json.xsl` (mounts, bitrates, listeners, `display-title`) plus the
homepage schedule grid, whose slots carry absolute `<local-time datetime>`
values. A memoised fetch and a lock keep a cold start to one homepage
download; the display-title's show is matched against the running slot for
"live from <city>".
- `beefy_player/providers/wordmark.py` — the half-block capitals behind every
hero, with per-character style spans.
- `beefy_player/widgets/schedule.py` — the left pane for single-stream
catalogues: the live slot with its `since`/`until` window, then upcoming
shows grouped by local day, with `… +N more` when the pane runs out.
@@ -419,7 +443,8 @@ tools/
~/.cache/beefy-player/channels-<id>.json last good list, other catalogues
~/.cache/beefy-player/schedule-<id>.json last good broadcast schedule
~/.cache/beefy-player/playlists.json resolved stream URLs
~/.cache/beefy-player/songs/<id>.json song history snapshots
~/.cache/beefy-player/songs/<id>.json SomaFM song history snapshots
~/.cache/beefy-player/songs/<p>-<id>.json history for every other catalogue
~/.cache/beefy-player/artwork/<sha1> cached album/channel artwork
```
@@ -488,6 +513,8 @@ so favourites and volume survive the rename.
- SomaFM is listener-supported: if you enjoy it, [donate](https://somafm.com/support/).
- tilderadio is a volunteer-run community station: [drop by](https://tilderadio.org),
or show up on the [schedule](https://tilderadio.org/schedule/) with a set.
- Bassdrive is free and listener-supported too: [bassdrive.com](https://bassdrive.com)
has the shows, archives and the shop that keeps the relays running.
## NixOS (optional aside)
+2
View File
@@ -46,6 +46,8 @@ class Broadcast:
name: str
dj: str = ""
#: Where the broadcast comes from ("Poznan, Poland"), when the feed says.
location: str = ""
start: datetime | None = None
end: datetime | None = None
#: True for the broadcast that is on air right now.
+3 -1
View File
@@ -17,6 +17,7 @@ Adding a catalogue is meant to be a small, contained change:
from __future__ import annotations
from .base import RadioProvider
from .bassdrive import BassdriveProvider
from .somafm import SomaFMProvider
from .tilderadio import TilderadioProvider
@@ -30,7 +31,8 @@ __all__ = [
#: Every catalogue the app can talk to, keyed by its slug.
PROVIDERS: dict[str, RadioProvider] = {
provider.id: provider for provider in (SomaFMProvider(), TilderadioProvider())
provider.id: provider
for provider in (SomaFMProvider(), TilderadioProvider(), BassdriveProvider())
}
#: Provider used when nothing else is configured.
@@ -0,0 +1,76 @@
"""Bassdrive: worldwide drum & bass, on air since the dial-up days.
One stream with three mounts, a schedule full of live DJ shows and a
``display-title`` that names the show rather than the track. Everything
Bassdrive-specific — the Icecast status schema, the homepage schedule grid, the
rolling show history and the bundled fallback — lives in this package; the app
only sees :class:`BassdriveProvider`.
"""
from __future__ import annotations
import httpx
from ... import config
from ...models import Broadcast, Song, Station
from ..base import RadioProvider
from . import api, fallback
from . import hero as hero_art
__all__ = ["BassdriveProvider"]
class BassdriveProvider(RadioProvider):
id = "bassdrive"
name = "Bassdrive"
homepage = "bassdrive.com"
brand = "BASSDRIVE"
tagline = "worldwide drum & bass · live shows"
station_source = "bassdrive.com + icecast status"
history_source = "icecast status (rolling)"
# The wordmark in the station's own two blues, in the shared half-block
# font; the caption is the station's own description.
hero = hero_art.HERO
hero_lines = (
"BASSDRIVE",
"WORLDWIDE DRUM & BASS",
"LIVE SHOWS 24/7",
)
# One stream and a schedule full of DJ slots: the left pane is the schedule.
has_schedule = True
# DJ-mix station: the rotation is not a Bandcamp catalogue.
bandcamp = False
@property
def user_agent(self) -> str:
return f"{config.USER_AGENT}; +https://bassdrive.com"
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
return [await api.fetch_station(client)]
def load_cached_stations(self) -> list[Station]:
return api.load_cached_station()
def fallback_stations(self) -> list[Station]:
return fallback.fallback_stations()
async def fetch_history(
self, client: httpx.AsyncClient, station_id: str
) -> list[Song]:
return await api.fetch_songs(client, station_id)
def load_cached_history(self, station_id: str) -> list[Song]:
return api.load_cached_songs(station_id)
async def fetch_schedule(
self, client: httpx.AsyncClient
) -> list[Broadcast]:
return await api.fetch_schedule(client)
def load_cached_schedule(self) -> list[Broadcast]:
return api.load_cached_schedule()
def fallback_urls(self, station: Station) -> list[str]:
return fallback.known_stream_urls()
+517
View File
@@ -0,0 +1,517 @@
"""Bassdrive's public endpoints, and nothing but.
* ``https://ice.bassdrive.net/status-json.xsl`` -> Icecast status: every mount
(mp3/aac+, bitrates), the listener counts and the current ``display-title``,
which for a DJ-mix stream is the *show*: ``"Ben XO :: LIVE :: Night Shift
Over"``.
* ``http://www.bassdrive.com/`` -> the homepage carries the whole week's
schedule, every slot stamped with an absolute ``<local-time datetime=...>``.
A DJ-mix station has no track history: Icecast reports one title and forgets
it. So "now playing" is the show (mapped onto ``Station.dj``/``presenter`` and
a :class:`Song`), and the history is a rolling record of the shows we have
heard since the app started — the unit a listener actually cares about here.
"""
from __future__ import annotations
import asyncio
import html as html_module
import re
import time
from collections.abc import Iterable
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
import httpx
from ... import config
from ...models import Broadcast, Song, Station, parse_date, to_int
from ..base import fetch_json
PROVIDER_ID = "bassdrive"
STATUS_URL = "https://ice.bassdrive.net/status-json.xsl"
SCHEDULE_URL = "https://www.bassdrive.com/"
DEFAULT_STATION_ID = "bassdrive"
STATION_IMAGE = "https://www.bassdrive.com/images/logo.png"
DESCRIPTION = "worldwide drum & bass radio — live DJ shows around the clock"
GENRE = "drum & bass|jungle"
WEEK = timedelta(days=7)
#: Upcoming slots kept in the pane (a week is plenty for a schedule).
SCHEDULE_LIMIT = 40
#: Shows remembered in LAST PLAYED.
HISTORY_LIMIT = 20
# --------------------------------------------------------------------------- #
# status-json -> Station / Song
# --------------------------------------------------------------------------- #
def _sources(payload: Any) -> list[dict[str, Any]]:
stats = payload.get("icestats") if isinstance(payload, dict) else None
source = stats.get("source") if isinstance(stats, dict) else None
if isinstance(source, dict):
return [source]
if isinstance(source, list):
return [entry for entry in source if isinstance(entry, dict)]
return []
def _quality(bitrate: int) -> str:
if bitrate >= 128:
return "highest"
if bitrate >= 48:
return "high"
return "low" if bitrate else ""
def playlists_from_status(payload: Any) -> list[dict[str, str]]:
playlists: list[dict[str, str]] = []
seen: set[str] = set()
for source in _sources(payload):
url = source.get("listenurl")
if not isinstance(url, str) or not url.startswith(("http://", "https://")):
continue
if url in seen:
continue
seen.add(url)
bitrate = to_int(source.get("bitrate"))
kind = str(source.get("server_type") or "").lower()
fmt = "mp3" if "mpeg" in kind else "aacp" if "aac" in kind else ""
playlists.append(
{
"url": url,
"format": fmt,
"quality": _quality(bitrate),
"bitrate": str(bitrate) if bitrate else "",
}
)
return playlists
def status_listeners(payload: Any) -> int:
"""Mounts split the audience, so the station's figure is their sum."""
return sum(to_int(source.get("listeners")) for source in _sources(payload))
def display_title(payload: Any) -> str:
for source in _sources(payload):
for key in ("display-title", "title"):
value = source.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def split_title(raw: str) -> tuple[str, str, bool]:
"""``"Ben XO :: LIVE :: Night Shift Over [promo]"`` -> DJ, show, live.
Bassdrive's title convention is ``DJ :: LIVE :: Show``, with promotional
brackets tacked on the end — but it is not enforced: plenty of shows go out
as ``"Sohl Patrol LIVE"``, so a trailing ``LIVE`` counts too. Anything
unexpected degrades to "the whole string is the show".
"""
cleaned = re.sub(r"\[[^\]]*\]", " ", raw)
parts = [part.strip() for part in cleaned.split("::") if part.strip()]
live = any(part.casefold() == "live" for part in parts)
parts = [part for part in parts if part.casefold() != "live"]
if parts:
trailing = re.search(r"(?i)\s+live$", parts[-1])
if trailing:
live = True
trimmed = parts[-1][: trailing.start()].strip()
parts = parts[:-1] + ([trimmed] if trimmed else [])
if not parts:
return "", raw.strip(), live
if len(parts) == 1:
return "", parts[0], live
return parts[0], parts[-1], live
def song_from_title(raw: str) -> Song | None:
dj, show, _live = split_title(raw)
if not (dj or show):
return None
# With no DJ in the title the station stands in as the "artist": a panel
# that says "Unknown artist" for every show would look broken.
return Song(artist=dj or "Bassdrive", title=show)
def _same_show(entry: Broadcast, show: str) -> bool:
return bool(show) and entry.name.strip().casefold() == show.strip().casefold()
def _slot_for(schedule: Iterable[Broadcast], show: str) -> Broadcast | None:
if not show:
return None
return next((entry for entry in schedule if _same_show(entry, show)), None)
def resolve_now(
payload: Any,
schedule: Iterable[Broadcast] = (),
now: datetime | None = None,
) -> tuple[str, str, bool, Broadcast | None]:
"""Who is on, according to the stream *and* the schedule.
The display-title often forgets the ``LIVE`` marker; a show whose slot
covers now counts as live even when the title is just its name.
"""
now = now or datetime.now(timezone.utc)
schedule = list(schedule)
dj, show, live = split_title(display_title(payload))
slot = _slot_for(schedule, show)
if (
slot is not None
and slot.start is not None
and slot.end is not None
and slot.start <= now < slot.end
):
live = True
return dj, show, live, slot
def station_from_status(
payload: Any, schedule: Iterable[Broadcast] = ()
) -> Station | None:
playlists = playlists_from_status(payload)
raw = display_title(payload)
if not playlists and not raw:
return None
dj, show, live, slot = resolve_now(payload, schedule)
location = slot.location if slot is not None else ""
if live:
presenter = f"live from {location}" if location else "live now"
else:
presenter = "rotation · drum & bass"
station = Station(
id=DEFAULT_STATION_ID,
title="Bassdrive",
description=DESCRIPTION,
genre=GENRE,
dj=dj,
presenter=presenter,
image=STATION_IMAGE,
listeners=status_listeners(payload),
playlists=playlists,
provider=PROVIDER_ID,
)
if show:
# The "artist" of a show-based stream is the station itself.
station.now_playing = Song(artist=dj or "Bassdrive", title=show)
station.last_playing = raw
return station
def live_broadcast(
payload: Any,
schedule: Iterable[Broadcast],
now: datetime | None = None,
) -> Broadcast | None:
dj, show, live, slot = resolve_now(payload, schedule, now)
if not live or not show:
return None
if slot is not None:
return Broadcast(
name=show,
dj=dj,
location=slot.location,
start=slot.start,
end=slot.end,
live=True,
)
return Broadcast(name=show, dj=dj, live=True)
# --------------------------------------------------------------------------- #
# the homepage schedule
# --------------------------------------------------------------------------- #
_SPAN_LOCATION = re.compile(r'<span class="location">(.*?)</span>', re.S)
def _text(value: str) -> str:
return re.sub(r"\s+", " ", html_module.unescape(re.sub(r"<[^>]+>", " ", value))).strip()
def parse_schedule(page: str) -> list[Broadcast]:
"""Pull the weekly grid out of the homepage, one ``<li>`` per slot."""
broadcasts: list[Broadcast] = []
for chunk in re.split(r"<li\b", page):
if 'class="title"' not in chunk or 'class="time"' not in chunk:
continue
stamps = re.findall(r'datetime="([^"]+)"', chunk)
if len(stamps) < 2:
continue
start = parse_date(stamps[0])
end = parse_date(stamps[1])
if start is None:
continue
title = re.search(r'<p class="title">(.*?)</p>', chunk, re.S)
if title is None:
continue
block = title.group(1)
location_match = _SPAN_LOCATION.search(block)
location = _text(location_match.group(1)) if location_match else ""
name = _text(_SPAN_LOCATION.sub(" ", block))
if not name:
continue
broadcasts.append(
Broadcast(name=name, location=location, start=start, end=end)
)
broadcasts.sort(key=lambda item: item.start or datetime.now(timezone.utc))
return broadcasts
def roll_forward(
entries: Iterable[Broadcast], now: datetime, limit: int = SCHEDULE_LIMIT
) -> list[Broadcast]:
"""The homepage only shows one week; repeat it so the pane is never empty."""
upcoming: list[Broadcast] = []
for entry in entries:
start, end = entry.start, entry.end
if start is None:
continue
for _ in range(3):
if end is None or end >= now:
break
start += WEEK
end = end + WEEK if end is not None else None
if end is not None and end < now:
continue
upcoming.append(
Broadcast(
name=entry.name,
dj=entry.dj,
location=entry.location,
start=start,
end=end,
)
)
upcoming.sort(key=lambda item: item.start or now)
return upcoming[:limit]
# --------------------------------------------------------------------------- #
# caching
# --------------------------------------------------------------------------- #
#: One homepage fetch serves both the station and the schedule worker; the
#: lock keeps a cold start from downloading the same grid twice at once.
_MEMO_TTL = 120.0
_schedule_memo: tuple[float, list[Broadcast]] | None = None
_schedule_lock = asyncio.Lock()
def _memo_schedule() -> list[Broadcast]:
if _schedule_memo is None:
return []
fetched, entries = _schedule_memo
if time.time() - fetched > _MEMO_TTL:
return []
return list(entries)
def _remember_schedule(entries: list[Broadcast]) -> None:
global _schedule_memo
_schedule_memo = (time.time(), entries)
def reset_schedule_memo() -> None:
"""Forget the in-process schedule (the tests use this)."""
global _schedule_memo
_schedule_memo = None
def _songs_cache_path(station_id: str) -> Path:
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", station_id or DEFAULT_STATION_ID)
return config.SONGS_CACHE_DIR / f"{PROVIDER_ID}-{safe}.json"
async def schedule_entries(client: httpx.AsyncClient) -> list[Broadcast]:
"""The weekly grid: memo, then the homepage, then the cached copy."""
remembered = _memo_schedule()
if remembered:
return remembered
async with _schedule_lock:
remembered = _memo_schedule()
if remembered:
return remembered
try:
response = await client.get(
SCHEDULE_URL, headers={"Accept": "text/html"}
)
response.raise_for_status()
entries = parse_schedule(response.text)
except httpx.HTTPError:
entries = []
if not entries:
return load_cached_schedule()
_remember_schedule(entries)
config.write_json(
config.schedule_cache_path(PROVIDER_ID),
{
"fetched": time.time(),
"schedule": [
{
"name": item.name,
"location": item.location,
"start": item.start.isoformat() if item.start else "",
"end": item.end.isoformat() if item.end else "",
}
for item in entries
],
},
)
return entries
def load_cached_station() -> list[Station]:
raw = config.read_json(config.channels_cache_path(PROVIDER_ID))
status = raw.get("status") if isinstance(raw, dict) else None
if status is None:
return []
station = station_from_status(status, load_cached_schedule())
return [station] if station is not None else []
def load_cached_schedule() -> list[Broadcast]:
raw = config.read_json(config.schedule_cache_path(PROVIDER_ID))
entries = raw.get("schedule") if isinstance(raw, dict) else None
if not isinstance(entries, list):
return []
return _schedule_from_cache(entries)
def _schedule_from_cache(entries: list[Any]) -> list[Broadcast]:
broadcasts: list[Broadcast] = []
for entry in entries:
if not isinstance(entry, dict):
continue
start = parse_date(entry.get("start"))
end = parse_date(entry.get("end"))
name = str(entry.get("name") or "").strip()
if not name or start is None:
continue
broadcasts.append(
Broadcast(
name=name,
location=str(entry.get("location") or "").strip(),
start=start,
end=end,
)
)
broadcasts.sort(key=lambda item: item.start or datetime.now(timezone.utc))
return broadcasts
def load_cached_songs(station_id: str) -> list[Song]:
raw = config.read_json(_songs_cache_path(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(),
played_at=parse_date(entry.get("played_at")),
)
if song.title or song.artist:
songs.append(song)
return songs
def _songs_to_cache(songs: Iterable[Song]) -> list[dict[str, Any]]:
return [
{
"title": song.title,
"artist": song.artist,
"played_at": song.played_at.isoformat() if song.played_at else "",
}
for song in songs
]
# --------------------------------------------------------------------------- #
# fetching
# --------------------------------------------------------------------------- #
async def fetch_station(client: httpx.AsyncClient) -> Station:
try:
payload = await fetch_json(client, STATUS_URL)
except RuntimeError as exc:
raise RuntimeError(f"could not fetch the Bassdrive status: {exc}") from None
# The schedule tells the station *where* a live show is coming from, so a
# cold start fetches it here; the schedule worker then reuses the memo.
schedule = load_cached_schedule() or await schedule_entries(client)
station = station_from_status(payload, schedule)
if station is None:
raise RuntimeError("Bassdrive returned no station data")
config.write_json(
config.channels_cache_path(PROVIDER_ID),
{"fetched": time.time(), "status": payload},
)
return station
async def fetch_songs(client: httpx.AsyncClient, station_id: str) -> list[Song]:
try:
payload = await fetch_json(client, STATUS_URL, attempts=1)
except RuntimeError:
return load_cached_songs(station_id)
current = song_from_title(display_title(payload))
if current is None:
return load_cached_songs(station_id)
history = load_cached_songs(station_id)
now = datetime.now(timezone.utc)
if history and history[0].key() == current.key():
# Same show still on air: keep the time we first heard it, so the pane
# says how long it has been running, not how long since the last poll.
current.played_at = history[0].played_at
history = [current, *history[1:]]
else:
current.played_at = now
history = [current] + [song for song in history if song.key() != current.key()]
history = history[:HISTORY_LIMIT]
config.write_json(
_songs_cache_path(station_id),
{"fetched": time.time(), "songs": _songs_to_cache(history)},
)
return history
async def fetch_schedule(client: httpx.AsyncClient) -> list[Broadcast]:
now = datetime.now(timezone.utc)
live: Broadcast | None = None
try:
status = await fetch_json(client, STATUS_URL, attempts=1)
except RuntimeError:
status = None
entries = await schedule_entries(client)
upcoming = roll_forward(entries, now)
if status is not None:
live = live_broadcast(status, entries, now)
if live is not None:
key = live.key()
upcoming = [item for item in upcoming if item.key() != key]
return [live, *upcoming]
return upcoming
@@ -0,0 +1,56 @@
"""Bundled station, so a cold offline start still makes sense.
Bassdrive is one station with three mounts and a handful of mirror hosts; the
streams below are the ones its own ``.m3u`` files list. The live status-json
always wins when it is reachable.
"""
from __future__ import annotations
from typing import Any
# (url, format, quality, bitrate)
_MOUNTS: tuple[tuple[str, str, str, str], ...] = (
("https://ice.bassdrive.net:443/stream", "mp3", "highest", "128"),
("https://ice.bassdrive.net:443/stream56", "aacp", "high", "56"),
("https://ice.bassdrive.net:443/stream32", "aacp", "low", "32"),
)
#: Alternative servers from bassdrive.m3u, tried when the primary is down.
_MIRRORS: tuple[str, ...] = (
"http://chi.bassdrive.co:80",
"http://bassdrive.radioca.st:8702",
"http://stream.bassdrive.uk:8200",
"http://au.bassdrive.co:8000",
)
_SERVER_TYPE = {"mp3": "audio/mpeg", "aacp": "audio/aacp"}
def fallback_status() -> dict[str, Any]:
"""A ``status-json.xsl``-shaped payload, so the normal parser can eat it."""
return {
"icestats": {
"source": [
{
"listenurl": url,
"bitrate": int(bitrate),
"server_type": _SERVER_TYPE.get(fmt, "audio/mpeg"),
"listeners": 0,
"display-title": "",
}
for url, fmt, _quality, bitrate in _MOUNTS
]
}
}
def fallback_stations() -> list[Any]:
from .api import station_from_status
station = station_from_status(fallback_status())
return [station] if station is not None else []
def known_stream_urls() -> list[str]:
return [url for url, _format, _quality, _bitrate in _MOUNTS] + list(_MIRRORS)
+14
View File
@@ -0,0 +1,14 @@
"""Bassdrive's header hero: its wordmark in the station's two blues.
The logo paints "BASS" in the pale blue and "DRIVE" in the deeper one; those
map onto the stock palette's bright cyan and cyan, so the wordmark still reads
as Bassdrive without hard-coded RGB.
"""
from __future__ import annotations
from ..wordmark import render
HERO: tuple[str, ...] = render(
"BASSDRIVE", ["bright_cyan"] * 4 + ["cyan"] * 5
)
+5 -6
View File
@@ -1,17 +1,16 @@
"""SomaFM's header hero: the block-letter wordmark and the three lines beside it.
The glyphs are plain half-blocks; the header paints them with the app's
The glyphs come from the shared half-block font
(:mod:`beefy_player.providers.wordmark`); the header paints them with the app's
spectrum gradient (``hero_gradient``), so the logo takes on the terminal's own
colours instead of carrying RGB values around.
"""
from __future__ import annotations
LOGO: tuple[str, ...] = (
"▄▀▀▀ ▄▀▀▄ █▄ ▄█ ▄▀▀▄",
"▀▀▀▄ █ █ █ █ █ █▀▀█",
"▄▄▄▀ ▀▄▄▀ █ █ █ █",
)
from ..wordmark import render
LOGO: tuple[str, ...] = render("SOMAFM")
TAGLINES: tuple[str, ...] = (
"SOMA FM RADIO",
+6 -19
View File
@@ -1,24 +1,11 @@
"""Generated header art — do not edit by hand.
"""tilderadio's header hero: its name in the shared half-block font.
Source: https://tilderadio.org/logos/tilderadio.png
Regenerate with::
uv run python tools/ansi_art.py --url https://tilderadio.org/logos/tilderadio.png --cols 22 --rows 7 --module beefy_player/providers/tilderadio/hero.py
Each row is rich markup; ``▀`` is a half block whose foreground is the top
pixel and whose background is the pixel below it. Colours are stock ANSI
palette names, so the art keeps the terminal's own palette.
No colours of its own — the wordmark inherits the terminal's default
foreground, so it looks like part of the chrome rather than a logo pasted in.
"""
from __future__ import annotations
HERO: tuple[str, ...] = (
' [bright_black]▄[/][bright_black on bright_black]▀▀[/][bright_black]▀[/]',
' [bright_black]▄▄[/][bright_black on bright_black]▀▀▀[/][bright_black]▄▄▄▄▄▄▄▄▄▄▄[/]',
'[bright_black]▄[/][bright_black on bright_black]▀[/][bright_black on black]▀▀▀▀▀▀▀▀▀▀▀[/][bright_black on bright_black]▀▀▀▀▀▀[/][bright_black on black]▀[/][bright_black on bright_black]▀[/][bright_black]▄[/]',
'[bright_black on bright_black]▀▀[/][black on black]▀▀▀▀▀▀▀▀▀▀[/][bright_black on bright_black]▀▀[/][bright_black]▀[/] [bright_black on bright_black]▀▀▀▀[/]',
'[bright_black on bright_black]▀▀[/][black on black]▀[/][black on bright_black]▀▀[/][black on green]▀[/][black on bright_black]▀▀[/][black on black]▀▀[/][black on bright_black]▀[/][black on black]▀▀[/][bright_black on black]▀[/][bright_black on bright_black]▀[/][bright_black]▄▄[/][bright_black on bright_black]▀▀[/][bright_black on black]▀[/][bright_black on bright_black]▀▀[/]',
'[bright_black on bright_black]▀▀[/][black on black]▀[/][green on green]▀[/][green on black]▀▀▀[/][green on green]▀▀▀▀[/][bright_black on black]▀[/][black on black]▀▀▀[/][bright_black on bright_black]▀[/][bright_black on black]▀[/][bright_black on bright_black]▀▀[/][black on black]▀[/][bright_black on bright_black]▀▀[/]',
'[bright_black]▀▀[/][bright_black on bright_black]▀[/][black on bright_black]▀▀[/][bright_black on bright_black]▀[/][black on bright_black]▀▀▀▀▀[/][bright_black on bright_black]▀[/][black on bright_black]▀[/][bright_black on bright_black]▀[/][black on bright_black]▀[/][bright_black on bright_black]▀▀▀[/][black on bright_black]▀[/][bright_black on bright_black]▀[/][bright_black]▀▀[/]',
)
from ..wordmark import render
HERO: tuple[str, ...] = render("TILDERADIO")
+91
View File
@@ -0,0 +1,91 @@
"""A half-block wordmark font for the header hero.
Every shipped catalogue draws its brand the same way: four-column capitals,
six pixels tall, emitted as three rows of half blocks (``▀``/``▄``/``█``) — the
same shape as the original SomaFM logo. Catalogues differ only in the word
they spell and the ANSI colours they paint it with, so the font lives here and
each provider's ``hero.py`` is one line of data.
Glyphs are 6x4 bitmaps (``M`` is 6x5). Two pixel rows become one text row, so
a wordmark is always three rows tall and the header keeps a fixed height.
"""
from __future__ import annotations
from collections.abc import Sequence
#: 6-row bitmap per capital; all rows of one glyph are the same width.
GLYPHS: dict[str, tuple[str, ...]] = {
"A": (".##.", "#..#", "####", "#..#", "#..#", "#..#"),
"B": ("###.", "#..#", "###.", "#..#", "#..#", "###."),
"D": ("###.", "#..#", "#..#", "#..#", "#..#", "###."),
"E": ("####", "#...", "###.", "#...", "#...", "####"),
"F": ("####", "#...", "###.", "#...", "#...", "#..."),
"I": ("###", ".#.", ".#.", ".#.", ".#.", "###"),
"L": ("#...", "#...", "#...", "#...", "#...", "####"),
# 5 wide on purpose: the M is the odd one out in the original wordmark.
"M": ("#...#", "##.##", "#.#.#", "#.#.#", "#...#", "#...#"),
"O": (".##.", "#..#", "#..#", "#..#", "#..#", ".##."),
"R": ("###.", "#..#", "###.", "#.#.", "#..#", "#..#"),
"S": (".###", "#...", "###.", "...#", "...#", "###."),
"T": ("###", ".#.", ".#.", ".#.", ".#.", ".#."),
"V": ("#..#", "#..#", "#..#", "#..#", ".##.", ".##."),
}
ROWS = 3
_GAP = " "
def _cell(top: str, bottom: str) -> str:
if top == "#" and bottom == "#":
return "█"
if top == "#":
return "▀"
if bottom == "#":
return "▄"
return " "
def _glyph_rows(char: str) -> tuple[str, ...]:
bitmap = GLYPHS.get(char)
if bitmap is None:
raise KeyError(f"no wordmark glyph for {char!r}")
return tuple(
"".join(_cell(bitmap[row * 2][col], bitmap[row * 2 + 1][col]) for col in range(len(bitmap[0])))
for row in range(ROWS)
)
def render(text: str, styles: Sequence[str | None] | None = None) -> tuple[str, ...]:
"""Spell ``text`` as three rows of half blocks.
``styles`` gives one rich-markup style per character (``None`` leaves it in
the terminal's default colour). Contiguous runs of the same style share a
markup span, so ``render("BASSDRIVE", ...)`` paints the first four letters
and the last five separately.
"""
if styles is not None and len(styles) != len(text):
raise ValueError("styles must provide one entry per character")
glyphs = [_glyph_rows(char.upper()) for char in text]
rows: list[str] = []
for row in range(ROWS):
cells: list[tuple[str | None, str]] = []
for index, glyph in enumerate(glyphs):
style = styles[index] if styles is not None else None
if index:
cells.append((style, _GAP))
cells.extend((style, char) for char in glyph[row])
while cells and cells[-1][1] == " ":
cells.pop()
segments: list[tuple[str | None, str]] = []
for style, char in cells:
if segments and segments[-1][0] == style:
segments[-1] = (style, segments[-1][1] + char)
else:
segments.append((style, char))
rows.append(
"".join(f"[{style}]{body}[/]" if style else body for style, body in segments)
)
return tuple(rows)
+2 -2
View File
@@ -1,13 +1,13 @@
[project]
name = "beefy-player"
version = "0.1.0"
description = "A flashy terminal player for internet radio — live spectrum analyser, MPRIS / macOS Now Playing support, song history and pluggable station catalogues (SomaFM and tilderadio included)"
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)"
readme = "README.md"
license = "WTFPL"
license-files = ["LICENSE"]
requires-python = ">=3.10"
authors = [{ name = "beefy-player contributors" }]
keywords = ["radio", "internet-radio", "tui", "terminal", "mpris", "audio", "somafm", "tilderadio"]
keywords = ["radio", "internet-radio", "tui", "terminal", "mpris", "audio", "somafm", "tilderadio", "bassdrive"]
classifiers = [
"Environment :: Console",
"Intended Audience :: End Users/Desktop",
+278
View File
@@ -0,0 +1,278 @@
"""Bassdrive: Icecast status, show titles, the homepage schedule, rolling history."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import httpx
import pytest
from beefy_player.models import Broadcast
from beefy_player.providers.bassdrive import BassdriveProvider, api
STATUS = {
"icestats": {
"source": [
{
"listenurl": "https://ice.bassdrive.net:443/stream",
"bitrate": 128,
"server_type": "audio/mpeg",
"listeners": 31,
"display-title": "Ben XO :: LIVE :: Night Shift Over [discord.gg/bassdrive]",
},
{
"listenurl": "https://ice.bassdrive.net:443/stream56",
"bitrate": 56,
"server_type": "audio/aacp",
"listeners": 5,
"display-title": "Ben XO :: LIVE :: Night Shift Over [discord.gg/bassdrive]",
},
{
"listenurl": "https://ice.bassdrive.net:443/stream32",
"bitrate": 32,
"server_type": "audio/aacp",
"listeners": 2,
"display-title": "Ben XO :: LIVE :: Night Shift Over [discord.gg/bassdrive]",
},
]
}
}
# Trimmed from the real homepage: the same markup the site ships.
SCHEDULE_PAGE = """
<ul class="schedule">
<li>
<a href="radio/get-dipped">
<p class="title">Get Dipped <span class="location">Los Angeles, CA</span></p>
<span class="time">
<local-time datetime="2026-11-03T19:00:00-05:00" hour="numeric"></local-time> -
<local-time datetime="2026-11-03T21:00:00-05:00" hour="numeric"></local-time>
</span>
</a>
</li>
<li>
<a href="radio/drumobsession">
<p class="title">DrumObsession <span class="location">Poznan, Poland</span></p>
<span class="time">
<local-time datetime="2026-11-03T11:00:00-05:00" hour="numeric"></local-time> -
<local-time datetime="2026-11-03T13:00:00-05:00" hour="numeric"></local-time>
</span>
</a>
</li>
</ul>
"""
@pytest.fixture(autouse=True)
def fresh_memo():
api.reset_schedule_memo()
yield
api.reset_schedule_memo()
def test_split_title_reads_both_conventions() -> None:
assert api.split_title("Ben XO :: LIVE :: Night Shift Over [promo]") == (
"Ben XO",
"Night Shift Over",
True,
)
# plenty of shows simply append LIVE
assert api.split_title("Sohl Patrol LIVE") == ("", "Sohl Patrol", True)
assert api.split_title("Bassdrive Auto DJ") == ("", "Bassdrive Auto DJ", False)
def test_mounts_become_playlists_best_first_and_listeners_add_up() -> None:
playlists = api.playlists_from_status(STATUS)
assert [entry["bitrate"] for entry in playlists] == ["128", "56", "32"]
assert [entry["quality"] for entry in playlists] == ["highest", "high", "low"]
assert playlists[0]["format"] == "mp3" and playlists[1]["format"] == "aacp"
assert api.status_listeners(STATUS) == 38
def test_parse_schedule_reads_the_homepage_grid() -> None:
entries = api.parse_schedule(SCHEDULE_PAGE)
assert [entry.name for entry in entries] == ["DrumObsession", "Get Dipped"]
assert entries[0].location == "Poznan, Poland"
assert entries[0].start == datetime(2026, 11, 3, 11, tzinfo=timezone(timedelta(hours=-5)))
assert entries[0].end == datetime(2026, 11, 3, 13, tzinfo=timezone(timedelta(hours=-5)))
# the schedule list itself is not a schedule slot
assert api.parse_schedule("<ul><li>nothing here</li></ul>") == []
def test_roll_forward_repeats_the_week() -> None:
start = datetime(2026, 11, 3, 11, tzinfo=timezone(timedelta(hours=-5)))
entry = Broadcast(
name="DrumObsession",
start=start,
end=start + timedelta(hours=2),
)
now = start + timedelta(days=1)
rolled = api.roll_forward([entry], now)
assert len(rolled) == 1
assert rolled[0].start == start + timedelta(days=7)
assert rolled[0].start >= now
# an entry that is over and cannot be rolled into the future is dropped
far = start + timedelta(days=40)
assert api.roll_forward([entry], far) == []
def test_station_maps_the_title_and_finds_the_live_location() -> None:
now = datetime.now(timezone.utc)
slot = Broadcast(
name="Night Shift Over",
location="London, UK",
start=now - timedelta(minutes=30),
end=now + timedelta(minutes=30),
)
station = api.station_from_status(STATUS, [slot])
assert station is not None
assert station.id == "bassdrive"
assert station.dj == "Ben XO"
assert station.presenter == "live from London, UK"
assert station.now_playing is not None
assert station.now_playing.title == "Night Shift Over"
assert station.now_playing.artist == "Ben XO"
assert station.listeners == 38
assert station.format_label == "mp3 · 128k"
def test_a_title_without_a_live_marker_still_counts_when_the_slot_is_running() -> None:
now = datetime.now(timezone.utc)
status = {
"icestats": {
"source": [
{
"listenurl": "https://ice.bassdrive.net:443/stream",
"bitrate": 128,
"server_type": "audio/mpeg",
"listeners": 3,
"display-title": "Sohl Patrol",
}
]
}
}
slot = Broadcast(
name="Sohl Patrol",
location="White Plains, NY, USA",
start=now - timedelta(minutes=10),
end=now + timedelta(minutes=50),
)
live = api.live_broadcast(status, [slot], now)
assert live is not None and live.live is True
assert live.location == "White Plains, NY, USA"
def test_station_without_title_or_mounts_is_not_a_station() -> None:
assert api.station_from_status({"icestats": {}}) is None
def client_for(
status_payload,
page: str = "",
*,
status_code: int = 200,
page_code: int = 200,
) -> httpx.AsyncClient:
def handler(request: httpx.Request) -> httpx.Response:
if str(request.url).endswith("status-json.xsl"):
return httpx.Response(status_code, json=status_payload)
return httpx.Response(page_code, text=page)
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
async def songs_for(payload, station_id: str = "bassdrive"):
async with client_for(payload) as client:
return await api.fetch_songs(client, station_id)
@pytest.mark.asyncio
async def test_fetch_schedule_puts_the_live_show_first_and_caches_the_grid() -> None:
now = datetime.now(timezone.utc)
payload = {
"icestats": {
"source": [
{
"listenurl": "https://ice.bassdrive.net:443/stream",
"bitrate": 128,
"server_type": "audio/mpeg",
"listeners": 9,
"display-title": "Get Dipped LIVE",
}
]
}
}
# the grid is generated relative to now so the live slot is really running
local = now.astimezone(timezone(timedelta(hours=-5)))
start = local - timedelta(minutes=15)
end = local + timedelta(minutes=45)
page = SCHEDULE_PAGE.replace("2026-11-03T19:00:00-05:00", start.isoformat()).replace(
"2026-11-03T21:00:00-05:00", end.isoformat()
)
async with client_for(payload, page) as client:
broadcast_list = await api.fetch_schedule(client)
assert broadcast_list[0].live is True
assert broadcast_list[0].name == "Get Dipped"
assert broadcast_list[0].location == "Los Angeles, CA"
assert broadcast_list[0].start.hour == start.hour
assert all(not item.live for item in broadcast_list[1:])
# the grid was written for offline starts
assert api.load_cached_schedule()
# ... and the second fetch reuses the memo instead of the network
async with client_for(payload, page_code=500) as client:
assert await api.fetch_schedule(client)
@pytest.mark.asyncio
async def test_history_is_a_rolling_record_of_shows() -> None:
first = await songs_for(STATUS)
assert [song.title for song in first] == ["Night Shift Over"]
# same show: the first-seen time is kept
again = await songs_for(STATUS)
assert again[0].played_at == first[0].played_at
other = {
"icestats": {
"source": [
{
"listenurl": "https://ice.bassdrive.net:443/stream",
"bitrate": 128,
"server_type": "audio/mpeg",
"listeners": 1,
"display-title": "Sohl Patrol LIVE",
}
]
}
}
moved = await songs_for(other)
assert [song.title for song in moved] == ["Sohl Patrol", "Night Shift Over"]
def test_provider_describes_its_catalogue() -> None:
provider = BassdriveProvider()
assert provider.has_schedule is True
assert provider.bandcamp is False
assert len(provider.hero) == 3
assert "bright_cyan" in "".join(provider.hero)
assert provider.hero_lines == (
"BASSDRIVE",
"WORLDWIDE DRUM & BASS",
"LIVE SHOWS 24/7",
)
stations = provider.fallback_stations()
assert [station.id for station in stations] == ["bassdrive"]
urls = provider.fallback_urls(stations[0])
assert urls[0].startswith("https://") and len(urls) == 7
@pytest.mark.asyncio
async def test_cold_start_survives_an_unreachable_homepage() -> None:
"""`fetch_station` asks for the grid; a broken homepage must not stop it."""
async with client_for(STATUS, page_code=500) as client:
station = await api.fetch_station(client)
assert station.id == "bassdrive"
assert station.presenter == "live now" # live, but the location is unknown
assert station.now_playing is not None
+49 -14
View File
@@ -6,8 +6,9 @@ import types
import pytest
from rich.cells import cell_len
from beefy_player.providers import PROVIDERS
from beefy_player.providers.somafm import SomaFMProvider
from beefy_player.providers.tilderadio import TilderadioProvider
from beefy_player.providers.wordmark import render
from beefy_player.widgets.header import HeaderBar
TIME = re.compile(r"\d\d:\d\d:\d\d$")
@@ -54,32 +55,66 @@ def test_right_block_hidden_on_narrow_terminals() -> None:
assert all(":" not in line for line in lines)
def test_somafm_hero_is_the_wordmark_and_its_taglines() -> None:
def test_somafm_hero_spells_the_whole_name_and_its_taglines() -> None:
lines = render_header(143)
assert lines[0].startswith("▄▀▀▀ ▄▀▀▄ █▄ ▄█ ▄▀▀▄")
assert lines[0].startswith(render("SOMAFM")[0])
assert "SOMA FM RADIO" in lines[0]
assert "LISTENER SUPPORTED" in lines[1]
assert "SAN FRANCISCO" in lines[2]
def test_tilderadio_hero_is_its_own_art_and_motto() -> None:
provider = TilderadioProvider()
lines = render_header(143, height=len(provider.hero), provider=provider)
assert len(lines) == len(provider.hero)
# the caption block is centred against the seven-row radio icon
assert "TILDERADIO" in lines[2]
assert "STREAMED BY / FOR USERS OF THE" in lines[3]
assert "TILDEVERSE" in lines[4]
# the radio icon's signature green wave survives the conversion
styles = [
def hero_styles(provider) -> list[str]:
return [
str(span.style)
for line in HeaderBar.render(_fake(provider)).renderables
for span in line.spans
]
assert any("green" in style for style in styles)
def test_the_i_and_t_have_thin_stems() -> None:
"""Two-pixel stems make an I look like an H and a T look like a block."""
assert render("I") == ("▀█▀", " █", "▄█▄")
assert render("T") == ("▀█▀", " █", " █")
def test_the_font_still_spells_soma_the_way_the_original_logo_did() -> None:
"""The wordmark font is the SomaFM logo, so the old art must be reproduced."""
assert render("SOMA") == (
"▄▀▀▀ ▄▀▀▄ █▄ ▄█ ▄▀▀▄",
"▀▀▀▄ █ █ █ █ █ █▀▀█",
"▄▄▄▀ ▀▄▄▀ █ █ █ █",
)
def test_every_catalogue_has_a_three_row_wordmark() -> None:
for slug, provider in PROVIDERS.items():
assert len(provider.hero) == 3, slug
assert provider.hero_lines, slug
def test_tilderadio_spells_its_own_name_in_the_default_colour() -> None:
provider = PROVIDERS["tilderadio"]
lines = render_header(143, height=len(provider.hero), provider=provider)
assert len(lines) == 3
assert "TILDERADIO" in lines[0] # the wordmark
assert "STREAMED BY / FOR USERS OF THE" in lines[1]
assert "TILDEVERSE" in lines[2]
# no colour of its own: the art inherits the terminal foreground
assert all("[" not in row for row in provider.hero)
assert not {"bright_cyan", "cyan"} & set(hero_styles(provider))
assert all("SOMA" not in line for line in lines)
def test_bassdrive_spells_its_name_in_its_own_two_blues() -> None:
provider = PROVIDERS["bassdrive"]
lines = render_header(143, height=len(provider.hero), provider=provider)
assert "BASSDRIVE" in lines[0]
assert "WORLDWIDE DRUM & BASS" in lines[1]
assert "LIVE SHOWS 24/7" in lines[2]
styles = hero_styles(provider)
assert "bright_cyan" in styles and "cyan" in styles
def _fake(provider):
fake = type("Fake", (), {})()
fake.app = types.SimpleNamespace(
+14 -11
View File
@@ -10,6 +10,7 @@ from beefy_player import config
from beefy_player.models import Song, Station
from beefy_player.providers import PROVIDERS, available_providers, get_provider
from beefy_player.providers.base import RadioProvider
from beefy_player.providers.bassdrive import BassdriveProvider
from beefy_player.providers.somafm import SomaFMProvider
from beefy_player.providers.tilderadio import TilderadioProvider
from beefy_player.widgets.header import HeaderBar
@@ -21,10 +22,11 @@ def rendered_text(app) -> str:
def test_registry_lists_somafm_as_the_default() -> None:
assert available_providers() == ("somafm", "tilderadio")
assert available_providers() == ("somafm", "tilderadio", "bassdrive")
assert isinstance(get_provider(), SomaFMProvider)
assert get_provider("somafm") is get_provider()
assert isinstance(get_provider("tilderadio"), TilderadioProvider)
assert isinstance(get_provider("bassdrive"), BassdriveProvider)
# a stale slug in the config file must not stop the app from starting
assert get_provider("no-such-catalogue") is get_provider()
@@ -182,10 +184,10 @@ async def test_p_opens_the_picker_and_switches_catalogue() -> None:
assert app.provider.id == "tilderadio"
assert app.config.provider == "tilderadio"
assert [station.id for station in app.stations] == ["tilderadio"]
# the left pane becomes the schedule, and the hero grows
# the left pane becomes the schedule; every hero is the same height
assert app.query_one(StationList).display is False
assert app.query_one(SchedulePanel).display is True
assert app.query_one(HeaderBar).styles.height.value == 8
assert app.query_one(HeaderBar).styles.height.value == 4
assert "tilderadio" in app.title
@@ -225,11 +227,12 @@ def test_config_remembers_a_station_per_catalogue() -> None:
@pytest.mark.asyncio
async def test_short_terminals_crop_the_hero_but_keep_the_ticker() -> None:
app = _CatalogueApp("tilderadio").app
async with app.run_test(size=(90, 24)) as pilot:
await pilot.pause()
header = app.query_one(HeaderBar)
assert header.styles.height.value <= 6
lines = [line.plain for line in header.render().renderables]
assert any("STOPPED" in line or "PLAYING" in line for line in lines)
async def test_every_catalogue_header_is_the_same_height() -> None:
for slug in available_providers():
app = _CatalogueApp(slug).app
async with app.run_test(size=(110, 40)) as pilot:
await pilot.pause()
header = app.query_one(HeaderBar)
assert header.styles.height.value == 4, slug
lines = [line.plain for line in header.render().renderables]
assert any("STOPPED" in line or "PLAYING" in line for line in lines), slug