"""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 ````. 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'(.*?)', 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 ``
  • `` per slot.""" broadcasts: list[Broadcast] = [] for chunk in re.split(r"(.*?)

    ', 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