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

+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