feat(bassdrive): add the drum & bass relay and a shared wordmark font
This commit is contained in:
1 parent
84ec3b08f6
commit
50a9a735f7
14 files changed
+1166
-79
No files matched your search
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user