Files
beefy-player/beefy_player/providers/bassdrive/api.py
T

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