518 lines
17 KiB
Python
518 lines
17 KiB
Python
"""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
|