diff --git a/README.md b/README.md index 7e6173e..eb23fe4 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,17 @@ song history, and full desktop media integration, drawn with your terminal's own ANSI palette on the terminal's default background so it fits any theme (including transparent terminals). -The station catalogue is pluggable. Beefy Player ships with -**[SomaFM](https://somafm.com)** (≈50 curated channels), -**[tilderadio](https://tilderadio.org)** (the tildeverse's single community -stream) and **[Bassdrive](https://bassdrive.com)** (worldwide drum & bass), and -the UI never talks to a station catalogue directly: it asks a +The station catalogue is pluggable. Beefy Player ships with thirteen catalogues +— **[SomaFM](https://somafm.com)** (≈50 curated channels), +**[Radio Paradise](https://radioparadise.com)** (five mixes), the community +stations **[tilderadio](https://tilderadio.org)**, +**[Bassdrive](https://bassdrive.com)**, **[dublab](https://dublab.com)**, +**[Rinse FM](https://rinse.fm)**, **[Refuge Worldwide](https://refugeworldwide.com)**, +**[LYL Radio](https://lyl.live)**, **[The Lot Radio](https://thelotradio.com)**, +**[Cashmere Radio](https://cashmereradio.com)**, +**[Datafruits](https://datafruits.fm)** and **[aNONradio](https://anonradio.net)**, +plus the **[Radio Browser](https://www.radio-browser.info)** directory +(300 stations) — and the UI never talks to a station catalogue directly: it asks a [provider](#providers) for stations, song history and stream URLs. Press `p` to switch catalogue without restarting. Adding another internet-radio catalogue is a self-contained module, not a rewrite. @@ -55,7 +61,9 @@ implemented here from public endpoints and open protocols. - **Last played** — the full recent history, newest first. - **Every station** — with SomaFM that is the whole `channels.json` roster (≈50 channels), live listener counts and genre search; tilderadio is a - single-station catalogue, and the list simply holds one. + single-station catalogue, and the list simply holds one. Long lists get a grey + thumb down the right edge (no rail) and a `12/51` position in the pane title; + `pgup`/`pgdn` move a screen at a time. - **Favourites** that float to the top, remembered between runs. - **Pluggable catalogues** — one `providers` module per radio network, with a `p` picker in the UI, `--provider` on the command line, and the choice @@ -197,23 +205,46 @@ layout and the guessed icecast mirrors all live in `beefy_player/providers/somafm/`. Nothing outside that package imports it; the app only ever holds a `RadioProvider`. -Three catalogues ship today: +Thirteen catalogues ship today: -| slug | catalogue | station list | history | streams | +| slug | catalogue | stations | what is on | relays | | --- | --- | --- | --- | --- | | `somafm` | [SomaFM](https://somafm.com) | `channels.json` (≈50) | `songs/.json` | `.pls` → icecast mirrors | -| `tilderadio` | [tilderadio](https://tilderadio.org) | `/api/now/` (1) | `now_playing` + `history[]` | AzuraCast mounts (mp3/ogg, 320k) | -| `bassdrive` | [Bassdrive](https://bassdrive.com) | icecast status (1) | rolling `display-title` | three mounts + mirrors | +| `radioparadise` | [Radio Paradise](https://radioparadise.com) | five mixes | `api/now_playing` (artist, album, cover) | FLAC / AAC / MP3 icecast | +| `tilderadio` | [tilderadio](https://tilderadio.org) | 1 + schedule | `/api/now/` | AzuraCast mounts | +| `bassdrive` | [Bassdrive](https://bassdrive.com) | 1 + schedule | icecast `display-title` (the show) | three mounts + mirrors | +| `dublab` | [dublab](https://dublab.com) | 1 + schedule | dublab's calendar | Airtime mp3 | +| `rinse` | [Rinse FM](https://rinse.fm) | 3 | — | proxy mounts (aac+) | +| `refuge` | [Refuge Worldwide](https://refugeworldwide.com) | 1 | `public.radio.co` status | radio.co mp3 | +| `lyl` | [LYL Radio](https://lyl.live) | 1 | — | icecast mp3 | +| `lot` | [The Lot Radio](https://thelotradio.com) | 1 | — | HLS | +| `cashmere` | [Cashmere Radio](https://cashmereradio.com) | 1 | — | Airtime ogg/mp3 | +| `datafruits` | [Datafruits](https://datafruits.fm) | 1 | streampusher icecast | relay mp3 | +| `anonradio` | [aNONradio](https://anonradio.net) | 1 | icecast title | icecast mp3 | +| `radiobrowser` | [Radio Browser](https://www.radio-browser.info) | 300 (directory) | — | whatever each station publishes | They are deliberately different shapes: fifty channels with PLS playlists, a single AzuraCast stream that carries its own history, and an Icecast relay whose "now playing" is the *show* rather than the track. That is the seam working. -The single-stream catalogues also use the optional capabilities: their left -pane is the broadcast schedule rather than a list containing one station, and -they opt out of Bandcamp lookups. Bassdrive is the extreme case — Icecast -reports one title and keeps no history, so the provider maps the title to -`Station.dj`/`presenter` and keeps a rolling record of the shows it has heard. +The single-stream catalogues also use the optional capabilities: where a +schedule exists (`tilderadio`, `bassdrive`, `dublab`) the left pane is that +schedule rather than a list containing one station, and none of them do +Bandcamp lookups. Bassdrive is the extreme case — Icecast reports one title and +keeps no history, so the provider maps the title to `Station.dj`/`presenter` +and keeps a rolling record of the shows it has heard. + +The community stations share one base class, +`providers/community/base.py`, which knows how to read an Icecast +`status-json.xsl`, a `public.radio.co` status, a scheduled show, or nothing at +all; each station is then ten lines of data (relays, wordmark, tagline). Where +a relay publishes no metadata (Rinse, LYL, The Lot, Cashmere) the app simply +plays it and says so, rather than inventing a track. + +`radiobrowser` is the directory case: three hundred of the most-clicked working +stations from the community database, keyless and public-domain, with no +per-station history at all — breadth rather than depth, and the one catalogue +where ON AIR legitimately has nothing to say. ### Header wordmarks @@ -306,6 +337,7 @@ The UI wants roughly **84×24**; it also works smaller, just less pretty. | key | action | | -------------- | --------------------------------------------- | | `↑` / `↓`, `j` / `k` | move through stations | +| `pgup` / `pgdn` | page through stations | | `enter` | tune in to the selected station | | `space` | play / pause | | `s` | stop | @@ -363,8 +395,11 @@ beefy_player/ base.py RadioProvider interface + HTTP/playlist toolkit wordmark.py the half-block font every hero is drawn with somafm/ the SomaFM catalogue (API, hero, bundled fallback list) + radiobrowser/ the community directory (300 stations, no metadata) + radioparadise/ five curated mixes with real track metadata tilderadio/ the tilderadio catalogue (one station, hero, schedule) bassdrive/ Bassdrive (Icecast status, show schedule, rolling history) + community/ one module per community station, on a shared base player.py mpv over a Unix-socket JSON IPC channel spectrum.py analyser (parec / Core Audio tap / ffmpeg / sim) macos_tap.py macOS output capture (ctypes + Core Audio) @@ -404,6 +439,10 @@ tools/ values. A memoised fetch and a lock keep a cold start to one homepage download; the display-title's show is matched against the running slot for "live from ". +- `beefy_player/providers/community/` — the community stations: `base.py` reads + Icecast or radio.co status, keeps the rolling history and turns a running + schedule slot into "what is on"; each station module is data. `rinse.py` + shows the multi-station case and `dublab.py` the schedule case. - `beefy_player/providers/wordmark.py` — the half-block capitals behind every hero, with per-character style spans. - `beefy_player/widgets/schedule.py` — the left pane for single-stream diff --git a/beefy_player/providers/__init__.py b/beefy_player/providers/__init__.py index ead4713..cf68f88 100644 --- a/beefy_player/providers/__init__.py +++ b/beefy_player/providers/__init__.py @@ -18,6 +18,18 @@ from __future__ import annotations from .base import RadioProvider from .bassdrive import BassdriveProvider +from .community import ( + AnonradioProvider, + CashmereProvider, + DatafruitsProvider, + DublabProvider, + LotProvider, + LylProvider, + RefugeProvider, + RinseProvider, +) +from .radiobrowser import RadioBrowserProvider +from .radioparadise import RadioParadiseProvider from .somafm import SomaFMProvider from .tilderadio import TilderadioProvider @@ -29,10 +41,28 @@ __all__ = [ "get_provider", ] -#: Every catalogue the app can talk to, keyed by its slug. +#: Every catalogue the app can talk to, keyed by its slug. The picker, the +#: help screen and ``--list`` all read this order: alphabetical by name. PROVIDERS: dict[str, RadioProvider] = { provider.id: provider - for provider in (SomaFMProvider(), TilderadioProvider(), BassdriveProvider()) + for provider in sorted( + ( + SomaFMProvider(), + TilderadioProvider(), + BassdriveProvider(), + RadioBrowserProvider(), + RadioParadiseProvider(), + DublabProvider(), + RinseProvider(), + RefugeProvider(), + LylProvider(), + LotProvider(), + CashmereProvider(), + DatafruitsProvider(), + AnonradioProvider(), + ), + key=lambda provider: provider.name.casefold(), + ) } #: Provider used when nothing else is configured. diff --git a/beefy_player/providers/base.py b/beefy_player/providers/base.py index 58055a5..56d0471 100644 --- a/beefy_player/providers/base.py +++ b/beefy_player/providers/base.py @@ -194,8 +194,9 @@ class RadioProvider(ABC): def is_playlist(url: str) -> bool: + """A playlist *we* resolve; HLS is handed to the player untouched.""" path = url.split("?", 1)[0].split("#", 1)[0].lower() - return path.endswith((".pls", ".m3u", ".m3u8")) + return path.endswith((".pls", ".m3u")) def is_direct_stream(url: str) -> bool: diff --git a/beefy_player/providers/community/__init__.py b/beefy_player/providers/community/__init__.py new file mode 100644 index 0000000..6083946 --- /dev/null +++ b/beefy_player/providers/community/__init__.py @@ -0,0 +1,29 @@ +"""Independent stations: one relay each, with the metadata the relay offers. + +Each module here is a thin subclass of +:class:`beefy_player.providers.community.base.CommunityProvider`, which knows +how to read an Icecast status document or a radio.co status, keep a rolling +history and (for Dublab) follow a published schedule. +""" + +from __future__ import annotations + +from .anonradio import AnonradioProvider +from .cashmere import CashmereProvider +from .datafruits import DatafruitsProvider +from .dublab import DublabProvider +from .lot import LotProvider +from .lyl import LylProvider +from .refuge import RefugeProvider +from .rinse import RinseProvider + +__all__ = [ + "AnonradioProvider", + "CashmereProvider", + "DatafruitsProvider", + "DublabProvider", + "LotProvider", + "LylProvider", + "RefugeProvider", + "RinseProvider", +] diff --git a/beefy_player/providers/community/anonradio.py b/beefy_player/providers/community/anonradio.py new file mode 100644 index 0000000..06b0633 --- /dev/null +++ b/beefy_player/providers/community/anonradio.py @@ -0,0 +1,26 @@ +"""aNONradio: SDF's community station, on Icecast since the dial was young.""" + +from __future__ import annotations + +from .base import CommunityProvider + + +class AnonradioProvider(CommunityProvider): + id = "anonradio" + name = "aNONradio" + homepage = "anonradio.net" + brand = "aNONradio" + tagline = "SDF community radio" + station_source = "anonradio.net icecast" + history_source = "icecast title (rolling)" + + station_title = "aNONradio" + station_description = ( + "community station of the SDF public-access UNIX system" + ) + station_genre = "eclectic|talk" + streams = (("https://anonradio.net:8443/anonradio", "mp3", "high", "192"),) + status_url = "https://anonradio.net:8443/status-json.xsl" + + wordmark = "ANONRADIO" + hero_lines = ("SDF COMMUNITY RADIO", "PUBLIC ACCESS UNIX") diff --git a/beefy_player/providers/community/base.py b/beefy_player/providers/community/base.py new file mode 100644 index 0000000..0120a00 --- /dev/null +++ b/beefy_player/providers/community/base.py @@ -0,0 +1,336 @@ +"""Community stations: one relay each, sometimes with a status document. + +Most independent stations are a single stream on Icecast, radio.co or Airtime, +plus — if you are lucky — a status document that names what is on. This module +turns that into a provider: the station, its relays, and a rolling history of +whatever the relay has announced while you were listening. + +A subclass only carries data. Stations with a schedule or a multi-station +catalogue (Dublab, Rinse, Radio Paradise) override a hook or two. +""" + +from __future__ import annotations + +import re +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import httpx + +from ... import config +from ...models import Broadcast, Song, Station, parse_date, parse_last_playing, to_int +from ..base import RadioProvider, fetch_json +from ..wordmark import render + +#: ``public.radio.co`` status document: keyless, no station id in the body. +RADIO_CO_STATUS = "https://public.radio.co/stations/{station}/status" + +#: How many shows/tracks LAST PLAYED remembers. +HISTORY_LIMIT = 20 + + +class CommunityProvider(RadioProvider): + """A catalogue that broadcasts one stream (or a fixed handful).""" + + # -- catalogue data (set by each station module) ---------------------- # + + station_title = "" + station_description = "" + station_genre = "eclectic" + station_image = "" + + #: ``(url, format, quality, bitrate)`` — the relays, best first. + streams: tuple[tuple[str, str, str, str], ...] = () + #: Keyless Icecast ``status-json.xsl``, when the relay publishes one. + status_url = "" + #: ``public.radio.co`` station id, when the relay is radio.co. + radio_co_id = "" + #: Wordmark the header spells; empty leaves the provider without a hero. + wordmark = "" + + # Independent stations are not Bandcamp catalogues. + bandcamp = False + + def __init__(self) -> None: + if self.wordmark: + self.hero = render(self.wordmark) + + # ------------------------------------------------------------------ # + # station + # ------------------------------------------------------------------ # + + def _playlists(self) -> list[dict[str, str]]: + return [ + {"url": url, "format": fmt, "quality": quality, "bitrate": bitrate} + for url, fmt, quality, bitrate in self.streams + ] + + def station(self) -> Station: + return Station( + id=self.id, + title=self.station_title or self.name, + description=self.station_description or self.tagline, + genre=self.station_genre, + image=self.station_image, + playlists=self._playlists(), + provider=self.id, + ) + + def stations(self) -> list[Station]: + return [self.station()] + + # ------------------------------------------------------------------ # + # what the relay says is on + # ------------------------------------------------------------------ # + + @property + def has_status(self) -> bool: + return bool(self.status_url or self.radio_co_id) + + async def _status(self, client: httpx.AsyncClient, attempts: int = 2) -> Any: + url = ( + RADIO_CO_STATUS.format(station=self.radio_co_id) + if self.radio_co_id + else self.status_url + ) + return await fetch_json(client, url, attempts=attempts) + + def _listeners(self, payload: Any) -> int: + if not isinstance(payload, dict): + return 0 + if self.radio_co_id: + return to_int(payload.get("listeners")) + stats = payload.get("icestats") + source = stats.get("source") if isinstance(stats, dict) else None + sources = ( + source + if isinstance(source, list) + else [source] + if isinstance(source, dict) + else [] + ) + return sum( + to_int(item.get("listeners")) for item in sources if isinstance(item, dict) + ) + + def _icecast_title(self, payload: Any) -> str: + stats = payload.get("icestats") if isinstance(payload, dict) else None + source = stats.get("source") if isinstance(stats, dict) else None + sources = ( + source + if isinstance(source, list) + else [source] + if isinstance(source, dict) + else [] + ) + for item in sources: + if not isinstance(item, dict): + continue + for key in ("display-title", "title", "yp_currently_playing"): + value = item.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + def _now(self, payload: Any) -> Song | None: + """The relay's current track/show, as a :class:`Song`.""" + if not isinstance(payload, dict): + return None + if self.radio_co_id: + track = payload.get("current_track") + if not isinstance(track, dict): + return None + title = str(track.get("title") or "").strip() + if not title: + return None + song = parse_last_playing(title) or Song(title=title) + song.art_url = str(track.get("artwork_url") or "").strip() + song.played_at = parse_date(track.get("start_time")) + return song + + title = self._icecast_title(payload) + if not title: + return None + return parse_last_playing(title) + + # ------------------------------------------------------------------ # + # the schedule, when the catalogue has one (Dublab) + # ------------------------------------------------------------------ # + + def _scheduled_now(self) -> Broadcast | None: + now = datetime.now(timezone.utc) + for entry in self.load_cached_schedule(): + if entry.start is None or entry.end is None: + continue + if entry.start <= now < entry.end: + return entry + return None + + def _mark_live(self, entries: list[Broadcast]) -> list[Broadcast]: + """Flag the slot that is running now, so the pane can say ON AIR.""" + now = datetime.now(timezone.utc) + marked: list[Broadcast] = [] + for entry in entries: + if ( + entry.start is not None + and entry.end is not None + and entry.start <= now < entry.end + ): + marked.append( + Broadcast( + name=entry.name, + dj=entry.dj, + location=entry.location, + start=entry.start, + end=entry.end, + live=True, + ) + ) + else: + marked.append(entry) + return marked + + def _scheduled_song(self) -> Song | None: + entry = self._scheduled_now() + if entry is None: + return None + return Song(artist=self.name, title=entry.name) + + # ------------------------------------------------------------------ # + # caching + # ------------------------------------------------------------------ # + + def _station_cache(self) -> Path: + return config.channels_cache_path(self.id) + + def _songs_cache(self, station_id: str) -> Path: + safe = re.sub(r"[^A-Za-z0-9_.-]", "_", station_id or self.id) + return config.SONGS_CACHE_DIR / f"{self.id}-{safe}.json" + + def _songs_to_cache(self, songs: list[Song]) -> list[dict[str, str]]: + return [ + { + "title": song.title, + "artist": song.artist, + "art": song.art_url, + "played_at": song.played_at.isoformat() if song.played_at else "", + } + for song in songs + ] + + # ------------------------------------------------------------------ # + # RadioProvider + # ------------------------------------------------------------------ # + + async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]: + stations = self.stations() + payload: Any = None + if self.has_status: + try: + payload = await self._status(client) + except RuntimeError: + payload = None + if payload is None: + payload = self._cached_status() + else: + config.write_json( + self._station_cache(), {"fetched": time.time(), "status": payload} + ) + + song = self._now(payload) if payload is not None else None + if song is None: + song = self._scheduled_song() + listeners = self._listeners(payload) if payload is not None else 0 + entry = self._scheduled_now() + + for station in stations: + if song is not None: + station.now_playing = song + station.last_playing = ( + f"{song.artist} - {song.title}" if song.artist else song.title + ) + if listeners: + station.listeners = listeners + if entry is not None: + station.presenter = ( + f"live from {entry.location}" if entry.location else "live now" + ) + return stations + + def _cached_status(self) -> Any: + raw = config.read_json(self._station_cache()) + return raw.get("status") if isinstance(raw, dict) else None + + def load_cached_stations(self) -> list[Station]: + stations = self.stations() + payload = self._cached_status() + song = self._now(payload) if payload is not None else None + if song is None: + song = self._scheduled_song() + listeners = self._listeners(payload) if payload is not None else 0 + for station in stations: + if song is not None: + station.now_playing = song + if listeners: + station.listeners = listeners + return stations + + def fallback_stations(self) -> list[Station]: + return self.stations() + + async def fetch_history( + self, client: httpx.AsyncClient, station_id: str + ) -> list[Song]: + current: Song | None = None + if self.has_status: + try: + payload = await self._status(client, attempts=1) + except RuntimeError: + payload = None + if payload is not None: + current = self._now(payload) + if current is None: + current = self._scheduled_song() + if current is None: + return self.load_cached_history(station_id) + + history = self.load_cached_history(station_id) + now = datetime.now(timezone.utc) + if history and history[0].key() == current.key(): + # Same show or track: keep the time we first heard it. + current.played_at = history[0].played_at + history = [current, *history[1:]] + else: + current.played_at = current.played_at or now + history = [current, *[s for s in history if s.key() != current.key()]] + history = history[:HISTORY_LIMIT] + + config.write_json( + self._songs_cache(station_id), + {"fetched": time.time(), "songs": self._songs_to_cache(history)}, + ) + return history + + def load_cached_history(self, station_id: str) -> list[Song]: + raw = config.read_json(self._songs_cache(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(), + art_url=str(entry.get("art") or "").strip(), + played_at=parse_date(entry.get("played_at")), + ) + if song.title or song.artist: + songs.append(song) + return songs + + def fallback_urls(self, station: Station) -> list[str]: + return [url for url, _format, _quality, _bitrate in self.streams] diff --git a/beefy_player/providers/community/cashmere.py b/beefy_player/providers/community/cashmere.py new file mode 100644 index 0000000..71dec9a --- /dev/null +++ b/beefy_player/providers/community/cashmere.py @@ -0,0 +1,26 @@ +"""Cashmere Radio: Berlin's experimental station, on an Airtime relay.""" + +from __future__ import annotations + +from .base import CommunityProvider + + +class CashmereProvider(CommunityProvider): + id = "cashmere" + name = "Cashmere Radio" + homepage = "cashmereradio.com" + brand = "CASHMERE" + tagline = "experimental radio · berlin" + station_source = "cashmereradio.com" + history_source = "" + + station_title = "Cashmere Radio" + station_description = "experimental radio from Berlin" + station_genre = "experimental|eclectic" + streams = ( + ("https://cashmereradio.out.airtime.pro/cashmereradio_a", "ogg", "high", "192"), + ("https://cashmereradio.out.airtime.pro/cashmereradio_b", "mp3", "high", "192"), + ) + + wordmark = "CASHMERE" + hero_lines = ("EXPERIMENTAL RADIO", "BERLIN") diff --git a/beefy_player/providers/community/datafruits.py b/beefy_player/providers/community/datafruits.py new file mode 100644 index 0000000..49a8a34 --- /dev/null +++ b/beefy_player/providers/community/datafruits.py @@ -0,0 +1,24 @@ +"""Datafruits: the netlabel's internet radio, relayed by Streampusher.""" + +from __future__ import annotations + +from .base import CommunityProvider + + +class DatafruitsProvider(CommunityProvider): + id = "datafruits" + name = "Datafruits" + homepage = "datafruits.fm" + brand = "DATAFRUITS" + tagline = "netlabel radio · experimental" + station_source = "streampusher relay + icecast status" + history_source = "icecast title (rolling)" + + station_title = "Datafruits" + station_description = "internet radio from the Datafruits netlabel" + station_genre = "electronic|experimental" + streams = (("https://streampusher-relay.club/datafruits.mp3", "mp3", "high", "128"),) + status_url = "https://streampusher-relay.club/status-json.xsl" + + wordmark = "DATAFRUITS" + hero_lines = ("NETLABEL RADIO", "EXPERIMENTAL ELECTRONIC") diff --git a/beefy_player/providers/community/dublab.py b/beefy_player/providers/community/dublab.py new file mode 100644 index 0000000..f76e35d --- /dev/null +++ b/beefy_player/providers/community/dublab.py @@ -0,0 +1,108 @@ +"""dublab: Los Angeles community radio, with a public weekly schedule.""" + +from __future__ import annotations + +import contextlib +import time +from datetime import datetime, timezone +from typing import Any + +import httpx + +from ... import config +from ...models import Broadcast, parse_date +from ..base import fetch_json +from .base import CommunityProvider + +SCHEDULE_URL = "https://dublab.com/.netlify/functions/schedule" + + +def parse_schedule(payload: Any) -> list[Broadcast]: + """The site's own calendar: ``[{startTime, endTime, summary, ...}]``.""" + if not isinstance(payload, list): + return [] + broadcasts: list[Broadcast] = [] + for entry in payload: + if not isinstance(entry, dict): + continue + name = str(entry.get("summary") or entry.get("title") or "").strip() + start = parse_date(entry.get("startTime") or entry.get("start")) + end = parse_date(entry.get("endTime") or entry.get("end")) + if not name or start is None: + continue + broadcasts.append(Broadcast(name=name, start=start, end=end)) + broadcasts.sort(key=lambda item: item.start or datetime.now(timezone.utc)) + return broadcasts + + +class DublabProvider(CommunityProvider): + id = "dublab" + name = "dublab" + homepage = "dublab.com" + brand = "DUBLAB" + tagline = "community radio · los angeles · since 1999" + station_source = "dublab.com schedule" + history_source = "dublab.com schedule (rolling)" + + station_title = "dublab" + station_description = "non-profit community radio from Los Angeles, since 1999" + station_genre = "eclectic|experimental" + streams = (("https://dublab.out.airtime.pro/dublab_a", "mp3", "high", "192"),) + + # The schedule is the only metadata dublab publishes, so it drives both the + # left pane and "what is on". + has_schedule = True + + wordmark = "DUBLAB" + hero_lines = ("LOS ANGELES", "SINCE 1999") + + async def fetch_schedule(self, client: httpx.AsyncClient) -> list[Broadcast]: + try: + payload = await fetch_json(client, SCHEDULE_URL, attempts=1) + except RuntimeError: + return self.load_cached_schedule() + entries = self._mark_live(parse_schedule(payload)) + if not entries: + return self.load_cached_schedule() + config.write_json( + config.schedule_cache_path(self.id), + { + "fetched": time.time(), + "schedule": [ + { + "name": item.name, + "start": item.start.isoformat() if item.start else "", + "end": item.end.isoformat() if item.end else "", + } + for item in entries + ], + }, + ) + return entries + + async def fetch_history(self, client: httpx.AsyncClient, station_id: str): + # The schedule is the only "what is on" dublab publishes, so make sure + # we have one before the base class looks for the running show. + if not self.load_cached_schedule(): + with contextlib.suppress(Exception): + await self.fetch_schedule(client) + return await super().fetch_history(client, station_id) + + def load_cached_schedule(self) -> list[Broadcast]: + raw = config.read_json(config.schedule_cache_path(self.id)) + entries = raw.get("schedule") if isinstance(raw, dict) else None + if not isinstance(entries, list): + return [] + broadcasts: list[Broadcast] = [] + for entry in entries: + if not isinstance(entry, dict): + continue + start = parse_date(entry.get("start")) + name = str(entry.get("name") or "").strip() + if not name or start is None: + continue + broadcasts.append( + Broadcast(name=name, start=start, end=parse_date(entry.get("end"))) + ) + broadcasts.sort(key=lambda item: item.start or datetime.now(timezone.utc)) + return broadcasts diff --git a/beefy_player/providers/community/lot.py b/beefy_player/providers/community/lot.py new file mode 100644 index 0000000..80da633 --- /dev/null +++ b/beefy_player/providers/community/lot.py @@ -0,0 +1,31 @@ +"""The Lot Radio: live from a shipping container in Brooklyn (HLS).""" + +from __future__ import annotations + +from .base import CommunityProvider + + +class LotProvider(CommunityProvider): + id = "lot" + name = "The Lot Radio" + homepage = "thelotradio.com" + brand = "THELOT" + tagline = "live from the lot · brooklyn" + station_source = "thelotradio.com (HLS)" + history_source = "" + + station_title = "The Lot Radio" + station_description = "live radio from a shipping container in Brooklyn" + station_genre = "eclectic|house" + # The station streams HLS; mpv plays the playlist directly. + streams = ( + ( + "https://livepeercdn.studio/hls/85c28sa2o8wppm58/index.m3u8", + "aac", + "high", + "", + ), + ) + + wordmark = "THELOT" + hero_lines = ("BROOKLYN", "LIVE FROM THE LOT") diff --git a/beefy_player/providers/community/lyl.py b/beefy_player/providers/community/lyl.py new file mode 100644 index 0000000..f4a72ec --- /dev/null +++ b/beefy_player/providers/community/lyl.py @@ -0,0 +1,23 @@ +"""LYL Radio: independent webradio from Lyon and Paris.""" + +from __future__ import annotations + +from .base import CommunityProvider + + +class LylProvider(CommunityProvider): + id = "lyl" + name = "LYL Radio" + homepage = "lyl.live" + brand = "LYL" + tagline = "we're the rest · lyon · paris" + station_source = "icecast.lyl.live" + history_source = "" + + station_title = "LYL Radio" + station_description = "independent webradio from Lyon and Paris" + station_genre = "eclectic|experimental" + streams = (("https://icecast.lyl.live/live", "mp3", "high", "192"),) + + wordmark = "LYL" + hero_lines = ("WE'RE THE REST", "LYON · PARIS") diff --git a/beefy_player/providers/community/refuge.py b/beefy_player/providers/community/refuge.py new file mode 100644 index 0000000..b8f6ed7 --- /dev/null +++ b/beefy_player/providers/community/refuge.py @@ -0,0 +1,24 @@ +"""Refuge Worldwide: Berlin community radio on a radio.co relay.""" + +from __future__ import annotations + +from .base import CommunityProvider + + +class RefugeProvider(CommunityProvider): + id = "refuge" + name = "Refuge Worldwide" + homepage = "refugeworldwide.com" + brand = "REFUGE" + tagline = "community radio · berlin" + station_source = "radio.co status" + history_source = "radio.co now playing (rolling)" + + station_title = "Refuge Worldwide" + station_description = "community radio from Berlin" + station_genre = "eclectic|electronic" + streams = (("https://streaming.radio.co/s3699c5e49/listen", "mp3", "high", "192"),) + radio_co_id = "s3699c5e49" + + wordmark = "REFUGE" + hero_lines = ("COMMUNITY RADIO", "BERLIN") diff --git a/beefy_player/providers/community/rinse.py b/beefy_player/providers/community/rinse.py new file mode 100644 index 0000000..4d5f0bc --- /dev/null +++ b/beefy_player/providers/community/rinse.py @@ -0,0 +1,66 @@ +"""Rinse FM: London's station family, a proxy mount each.""" + +from __future__ import annotations + +from ...models import Station +from .base import CommunityProvider + +#: (station id, title, genre, mount, description) +_STATIONS: tuple[tuple[str, str, str, str, str], ...] = ( + ( + "rinse_uk", + "Rinse UK", + "electronic|house", + "rinse_uk", + "London's underground dance station, on air since 1994", + ), + ( + "kool", + "Kool London", + "jungle|drum & bass", + "kool", + "Kool London: jungle, drum & bass and roots", + ), + ( + "swu", + "SWU", + "soul|eclectic", + "swu", + "SWU: soul, funk and everything south west", + ), +) + + +class RinseProvider(CommunityProvider): + id = "rinse" + name = "Rinse FM" + homepage = "rinse.fm" + brand = "RINSE" + tagline = "london · since 1994" + station_source = "rinse.fm streams" + history_source = "" + + station_genre = "electronic" + + wordmark = "RINSE" + hero_lines = ("LONDON", "SINCE 1994") + + def stations(self) -> list[Station]: + return [ + Station( + id=station_id, + title=title, + description=description, + genre=genre, + playlists=[ + { + "url": f"https://admin.stream.rinse.fm/proxy/{mount}/stream", + "format": "aacp", + "quality": "high", + "bitrate": "128", + } + ], + provider=self.id, + ) + for station_id, title, genre, mount, description in _STATIONS + ] diff --git a/beefy_player/providers/radiobrowser/__init__.py b/beefy_player/providers/radiobrowser/__init__.py new file mode 100644 index 0000000..c3a1bae --- /dev/null +++ b/beefy_player/providers/radiobrowser/__init__.py @@ -0,0 +1,59 @@ +"""Radio Browser: a directory of the community's stations rather than a station. + +Three hundred of the most-clicked working relays, keyless and public-domain. +There is no now-playing API, so this catalogue offers breadth, not history: pick +a station and the stream speaks for itself. +""" + +from __future__ import annotations + +import httpx + +from ... import config +from ...models import Song, Station +from ..base import RadioProvider +from . import api, fallback +from . import hero as hero_art + +__all__ = ["RadioBrowserProvider"] + + +class RadioBrowserProvider(RadioProvider): + id = "radiobrowser" + name = "Radio Browser" + homepage = "radio-browser.info" + brand = "RADIO BROWSER" + tagline = "community directory · 50,000 stations" + station_source = "radio-browser.info (top 300)" + history_source = "" + + hero = hero_art.HERO + hero_lines = ("RADIO BROWSER", "COMMUNITY DIRECTORY", "KEYLESS · OPEN DATA") + + # A directory has no show schedule and no Bandcamp worth scraping. + bandcamp = False + + @property + def user_agent(self) -> str: + return f"{config.USER_AGENT}; +https://www.radio-browser.info" + + async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]: + return await api.fetch_stations(client) + + def load_cached_stations(self) -> list[Station]: + return api.load_cached_stations() + + def fallback_stations(self) -> list[Station]: + return fallback.fallback_stations() + + async def fetch_history( + self, client: httpx.AsyncClient, station_id: str + ) -> list[Song]: + # Radio Browser publishes no now-playing data at all. + return [] + + def load_cached_history(self, station_id: str) -> list[Song]: + return [] + + def fallback_urls(self, station: Station) -> list[str]: + return [entry["url"] for entry in station.sorted_playlists()] diff --git a/beefy_player/providers/radiobrowser/api.py b/beefy_player/providers/radiobrowser/api.py new file mode 100644 index 0000000..39081b5 --- /dev/null +++ b/beefy_player/providers/radiobrowser/api.py @@ -0,0 +1,113 @@ +"""Radio Browser: the community's own station directory. + +``https://de1.api.radio-browser.info/json/stations/search`` is keyless, CORS-open +and public-domain data. It knows nothing about what a station is playing — +there is no per-station history — so this catalogue is a *directory*: three +hundred of the most-clicked working stations, ready to tune into. Whatever the +relay puts in its stream is what you hear; the app has no metadata to show. +""" + +from __future__ import annotations + +from typing import Any +from urllib.parse import urlencode + +import httpx + +from ... import config +from ...models import Station, to_int +from ..base import fetch_json + +API_URL = "https://de1.api.radio-browser.info/json/stations/search" + +#: Three hundred is a comfortable directory: enough to browse, quick to fetch. +STATION_LIMIT = 300 + +_PARAMS = { + "hidebroken": "true", + "order": "clickcount", + "reverse": "true", + "limit": str(STATION_LIMIT), +} + +_CODECS = {"mp3": "mp3", "aac": "aac", "aac+": "aacp", "ogg": "ogg", "opus": "opus"} + + +def _quality(bitrate: int) -> str: + if bitrate >= 192: + return "highest" + if bitrate >= 96: + return "high" + return "low" if bitrate else "" + + +def station_from_entry(entry: dict[str, Any]) -> Station | None: + if not isinstance(entry, dict): + return None + name = str(entry.get("name") or "").strip() + url = str(entry.get("url_resolved") or entry.get("url") or "").strip() + station_id = str(entry.get("stationuuid") or "").strip() + if not name or not station_id or not url.startswith(("http://", "https://")): + return None + + bitrate = to_int(entry.get("bitrate")) + codec = str(entry.get("codec") or "").strip().lower() + tags = [ + tag.strip() + for tag in str(entry.get("tags") or "").split(",") + if tag.strip() + ][:3] + country = str(entry.get("country") or "").strip() + language = str(entry.get("language") or "").strip() + description = " · ".join(part for part in (country, language) if part) + + favicon = str(entry.get("favicon") or "").strip() + + return Station( + id=station_id, + title=name, + description=description or "internet radio", + genre="|".join(tags), + image=favicon if favicon.startswith(("http://", "https://")) else "", + playlists=[ + { + "url": url, + "format": _CODECS.get(codec, codec), + "quality": _quality(bitrate), + "bitrate": str(bitrate) if bitrate else "", + } + ], + provider="radiobrowser", + ) + + +def parse_stations(payload: Any) -> list[Station]: + if not isinstance(payload, list): + return [] + stations: list[Station] = [] + seen: set[str] = set() + for entry in payload: + station = station_from_entry(entry) + if station is None or station.id in seen: + continue + seen.add(station.id) + stations.append(station) + stations.sort(key=lambda station: station.title.casefold()) + return stations + + +async def fetch_stations(client: httpx.AsyncClient) -> list[Station]: + url = f"{API_URL}?{urlencode(_PARAMS)}" + try: + payload = await fetch_json(client, url) + except RuntimeError as exc: + raise RuntimeError(f"could not fetch the Radio Browser directory: {exc}") from None + stations = parse_stations(payload) + if not stations: + raise RuntimeError("Radio Browser returned no stations") + config.write_json(config.channels_cache_path("radiobrowser"), payload) + return stations + + +def load_cached_stations() -> list[Station]: + return parse_stations(config.read_json(config.channels_cache_path("radiobrowser"))) diff --git a/beefy_player/providers/radiobrowser/fallback.py b/beefy_player/providers/radiobrowser/fallback.py new file mode 100644 index 0000000..88dde45 --- /dev/null +++ b/beefy_player/providers/radiobrowser/fallback.py @@ -0,0 +1,44 @@ +"""A handful of directory entries for a cold offline start. + +Radio Browser itself is the directory, but an offline start has no cache — so +this is ten stations whose relays have been up for years, one per taste. +""" + +from __future__ import annotations + +from ...models import Station + +#: (id, title, country, genre tags, url, format, bitrate) +_STATIONS: tuple[tuple[str, str, str, str, str, str, str], ...] = ( + ("fallback-groovesalad", "SomaFM Groove Salad", "United States", "ambient|chill", "https://ice1.somafm.com/groovesalad-128-mp3", "mp3", "128"), + ("fallback-radioparadise", "Radio Paradise", "United States", "eclectic|rock", "https://stream.radioparadise.com/flac", "flac", "1411"), + ("fallback-fip", "FIP", "France", "eclectic|jazz", "https://icecast.radiofrance.fr/fip-midfi.mp3", "mp3", "128"), + ("fallback-kexp", "KEXP", "United States", "alternative|indie", "https://kexp.streamguys1.com/kexp160.aac", "aac", "160"), + ("fallback-nts1", "NTS Radio 1", "United Kingdom", "eclectic|electronic", "https://stream-relay-geo.ntslive.net/stream", "mp3", "128"), + ("fallback-rinse", "Rinse UK", "United Kingdom", "house|electronic", "https://admin.stream.rinse.fm/proxy/rinse_uk/stream", "aacp", "128"), + ("fallback-dublab", "dublab", "United States", "eclectic|experimental", "https://dublab.out.airtime.pro/dublab_a", "mp3", "192"), + ("fallback-cashmere", "Cashmere Radio", "Germany", "experimental", "https://cashmereradio.out.airtime.pro/cashmereradio_a", "ogg", "192"), + ("fallback-datafruits", "Datafruits", "United States", "electronic|experimental", "https://streampusher-relay.club/datafruits.mp3", "mp3", "128"), + ("fallback-anonradio", "aNONradio", "United States", "eclectic|talk", "https://anonradio.net:8443/anonradio", "mp3", "192"), +) + + +def fallback_stations() -> list[Station]: + return [ + Station( + id=station_id, + title=title, + description=country, + genre=genre, + playlists=[ + { + "url": url, + "format": fmt, + "quality": "high", + "bitrate": bitrate, + } + ], + provider="radiobrowser", + ) + for station_id, title, country, genre, url, fmt, bitrate in _STATIONS + ] diff --git a/beefy_player/providers/radiobrowser/hero.py b/beefy_player/providers/radiobrowser/hero.py new file mode 100644 index 0000000..3c960fd --- /dev/null +++ b/beefy_player/providers/radiobrowser/hero.py @@ -0,0 +1,7 @@ +"""Radio Browser's header hero: the directory's own name.""" + +from __future__ import annotations + +from ..wordmark import render + +HERO: tuple[str, ...] = render("RADIOBROWSER") diff --git a/beefy_player/providers/radioparadise/__init__.py b/beefy_player/providers/radioparadise/__init__.py new file mode 100644 index 0000000..6d31596 --- /dev/null +++ b/beefy_player/providers/radioparadise/__init__.py @@ -0,0 +1,136 @@ +"""Radio Paradise: five hand-curated mixes, listener-supported since 2000. + +Unlike the community stations, Radio Paradise publishes a proper now-playing +API for every mix, with album and cover art — so this provider is a small +catalogue with real track metadata rather than a single relay. +""" + +from __future__ import annotations + +import re +import time +from datetime import datetime, timezone +from pathlib import Path + +import httpx + +from ... import config +from ...models import Song, Station +from ..base import RadioProvider +from ..wordmark import render +from . import api + +__all__ = ["RadioParadiseProvider"] + +#: How many tracks LAST PLAYED remembers per mix. +HISTORY_LIMIT = 20 + + +class RadioParadiseProvider(RadioProvider): + id = "radioparadise" + name = "Radio Paradise" + homepage = "radioparadise.com" + brand = "RADIO PARADISE" + tagline = "listener-supported · curated mixes" + station_source = "api.radioparadise.com/now_playing" + history_source = "api.radioparadise.com (rolling)" + + hero = render("PARADISE") + hero_lines = ("RADIO PARADISE", "LISTENER SUPPORTED", "PARADISE, CALIFORNIA") + + @property + def user_agent(self) -> str: + return f"{config.USER_AGENT}; +https://radioparadise.com" + + def stations(self) -> list[Station]: + return [ + Station( + id=station_id, + title=title, + description=f"Radio Paradise — {title}", + genre=genre, + playlists=api.playlists(names), + provider=self.id, + ) + for station_id, _chan, title, genre, names in api.CHANNELS + ] + + async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]: + return self.stations() + + def load_cached_stations(self) -> list[Station]: + return self.stations() + + def fallback_stations(self) -> list[Station]: + return self.stations() + + def _songs_cache(self, station_id: str) -> Path: + safe = re.sub(r"[^A-Za-z0-9_.-]", "_", station_id or self.id) + return config.SONGS_CACHE_DIR / f"{self.id}-{safe}.json" + + async def fetch_history( + self, client: httpx.AsyncClient, station_id: str + ) -> list[Song]: + chan = api.CHANNEL_BY_STATION.get(station_id) + if chan is None: + return self.load_cached_history(station_id) + + current = await api.fetch_now(client, chan) + if current is None: + return self.load_cached_history(station_id) + + history = self.load_cached_history(station_id) + if history and history[0].key() == current.key(): + current.played_at = history[0].played_at + history = [current, *history[1:]] + else: + current.played_at = datetime.now(timezone.utc) + history = [current, *[song for song in history if song.key() != current.key()]] + history = history[:HISTORY_LIMIT] + + config.write_json( + self._songs_cache(station_id), + { + "fetched": time.time(), + "songs": [ + { + "title": song.title, + "artist": song.artist, + "album": song.album, + "art": song.art_url, + "played_at": song.played_at.isoformat() + if song.played_at + else "", + } + for song in history + ], + }, + ) + return history + + def load_cached_history(self, station_id: str) -> list[Song]: + raw = config.read_json(self._songs_cache(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(), + album=str(entry.get("album") or "").strip(), + art_url=str(entry.get("art") or "").strip(), + played_at=None, + ) + if isinstance(entry.get("played_at"), str) and entry["played_at"]: + from ...models import parse_date + + song.played_at = parse_date(entry["played_at"]) + if song.title or song.artist: + songs.append(song) + return songs + + def fallback_urls(self, station: Station) -> list[str]: + return [entry["url"] for entry in station.sorted_playlists()] diff --git a/beefy_player/providers/radioparadise/api.py b/beefy_player/providers/radioparadise/api.py new file mode 100644 index 0000000..d011226 --- /dev/null +++ b/beefy_player/providers/radioparadise/api.py @@ -0,0 +1,85 @@ +"""Radio Paradise's public endpoints, and nothing but. + +* ``https://api.radioparadise.com/api/now_playing?chan=N`` -> the current track + (artist, title, album, year, cover art, seconds remaining) +* ``https://stream.radioparadise.com/`` -> the icecast relays + +Radio Paradise curates five mixes; each is one relay and one channel number on +the now-playing API. The main mix carries lossy relays as well as FLAC; the +alternate mixes are FLAC only. +""" + +from __future__ import annotations + +from typing import Any + +import httpx + +from ...models import Song +from ..base import fetch_json + +NOW_URL = "https://api.radioparadise.com/api/now_playing?chan={chan}" +STREAM_URL = "https://stream.radioparadise.com/{name}" + +FORMAT_LABELS = {"flac": ("flac", "highest"), "aac-320": ("aac", "high")} + +#: (station id, channel, title, genre, relay names) +CHANNELS: tuple[tuple[str, int, str, str, tuple[str, ...]], ...] = ( + ( + "main", + 0, + "The Main Mix", + "eclectic|rock", + ("flac", "aac-320", "mp3-320", "mp3-128"), + ), + ("mellow", 1, "Mellow Mix", "chill|eclectic", ("mellow-flac",)), + ("rock", 2, "RockIt!", "rock|alternative", ("rock-flac",)), + ("global", 3, "The Globe", "world|eclectic", ("global-flac",)), + ("beyond", 5, "Beyond...", "ambient|experimental", ("beyond-flac",)), +) + +CHANNEL_BY_STATION = {station_id: chan for station_id, chan, *_ in CHANNELS} + + +def parse_now(payload: Any) -> Song | None: + if not isinstance(payload, dict): + return None + artist = str(payload.get("artist") or "").strip() + title = str(payload.get("title") or "").strip() + if not (artist or title): + return None + return Song( + artist=artist, + title=title, + album=str(payload.get("album") or "").strip(), + art_url=str(payload.get("cover") or payload.get("cover_med") or "").strip(), + ) + + +async def fetch_now(client: httpx.AsyncClient, chan: int) -> Song | None: + try: + payload = await fetch_json(client, NOW_URL.format(chan=chan), attempts=1) + except RuntimeError: + return None + return parse_now(payload) + + +def playlists(names: tuple[str, ...]) -> list[dict[str, str]]: + """The relay list for one mix, best first.""" + entries: list[dict[str, str]] = [] + for name in names: + fmt, quality = FORMAT_LABELS.get(name, (name.split("-")[0], "high")) + bitrate = "" + if fmt == "flac": + bitrate = "1411" # nominal CD-quality FLAC rate + elif name.startswith(("aac-", "mp3-")): + bitrate = name.split("-", 1)[1] + entries.append( + { + "url": STREAM_URL.format(name=name), + "format": fmt, + "quality": quality, + "bitrate": bitrate, + } + ) + return entries diff --git a/beefy_player/providers/radioparadise/hero.py b/beefy_player/providers/radioparadise/hero.py new file mode 100644 index 0000000..8c88897 --- /dev/null +++ b/beefy_player/providers/radioparadise/hero.py @@ -0,0 +1,7 @@ +"""Radio Paradise's header hero: the one word it is named after.""" + +from __future__ import annotations + +from ..wordmark import render + +HERO: tuple[str, ...] = render("PARADISE") diff --git a/beefy_player/providers/wordmark.py b/beefy_player/providers/wordmark.py index 8416e7f..7e95a90 100644 --- a/beefy_player/providers/wordmark.py +++ b/beefy_player/providers/wordmark.py @@ -18,18 +18,27 @@ from collections.abc import Sequence GLYPHS: dict[str, tuple[str, ...]] = { "A": (".##.", "#..#", "####", "#..#", "#..#", "#..#"), "B": ("###.", "#..#", "###.", "#..#", "#..#", "###."), + "C": (".##.", "#..#", "#...", "#...", "#..#", ".##."), "D": ("###.", "#..#", "#..#", "#..#", "#..#", "###."), "E": ("####", "#...", "###.", "#...", "#...", "####"), "F": ("####", "#...", "###.", "#...", "#...", "#..."), + "G": (".##.", "#..#", "#...", "#.##", "#..#", ".##."), + "H": ("#..#", "#..#", "####", "#..#", "#..#", "#..#"), "I": ("###", ".#.", ".#.", ".#.", ".#.", "###"), + "K": ("#..#", "#.#.", "##..", "##..", "#.#.", "#..#"), "L": ("#...", "#...", "#...", "#...", "#...", "####"), # 5 wide on purpose: the M is the odd one out in the original wordmark. "M": ("#...#", "##.##", "#.#.#", "#.#.#", "#...#", "#...#"), + "N": ("#..#", "##.#", "##.#", "#.##", "#..#", "#..#"), "O": (".##.", "#..#", "#..#", "#..#", "#..#", ".##."), + "P": ("###.", "#..#", "###.", "#...", "#...", "#..."), "R": ("###.", "#..#", "###.", "#.#.", "#..#", "#..#"), "S": (".###", "#...", "###.", "...#", "...#", "###."), "T": ("###", ".#.", ".#.", ".#.", ".#.", ".#."), + "U": ("#..#", "#..#", "#..#", "#..#", "#..#", ".##."), "V": ("#..#", "#..#", "#..#", "#..#", ".##.", ".##."), + "W": ("#...#", "#...#", "#.#.#", "#.#.#", "##.##", "#...#"), + "Y": ("#..#", "#..#", ".##.", "..#.", "..#.", "..#."), } ROWS = 3 diff --git a/pyproject.toml b/pyproject.toml index 8cc8beb..b6c2a0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "beefy-player" version = "0.1.0" -description = "A flashy terminal player for internet radio — live spectrum analyser, MPRIS / macOS Now Playing support, song history and pluggable station catalogues (SomaFM, tilderadio and Bassdrive included)" +description = "A flashy terminal player for internet radio — live spectrum analyser, MPRIS / macOS Now Playing support, song history and pluggable station catalogues (SomaFM, Radio Paradise, dublab, Rinse FM and more)" readme = "README.md" license = "WTFPL" license-files = ["LICENSE"] requires-python = ">=3.10" authors = [{ name = "beefy-player contributors" }] -keywords = ["radio", "internet-radio", "tui", "terminal", "mpris", "audio", "somafm", "tilderadio", "bassdrive"] +keywords = ["radio", "internet-radio", "tui", "terminal", "mpris", "audio", "somafm", "tilderadio", "bassdrive", "radioparadise", "radiobrowser", "dublab"] classifiers = [ "Environment :: Console", "Intended Audience :: End Users/Desktop", diff --git a/tests/test_api.py b/tests/test_api.py index 53ac61c..97dd043 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -82,6 +82,9 @@ Version=2 assert base.is_playlist("https://x/y.pls?foo=1") is True assert base.is_direct_stream("https://ice1.somafm.com/x-128-mp3") is True assert base.is_direct_stream("https://x/y.m3u") is False + # HLS is the player's job, not ours: mpv reads the playlist itself + assert base.is_playlist("https://x/live/index.m3u8") is False + assert base.is_direct_stream("https://x/live/index.m3u8") is True def test_parse_songs_and_dates() -> None: diff --git a/tests/test_community.py b/tests/test_community.py new file mode 100644 index 0000000..7d860e1 --- /dev/null +++ b/tests/test_community.py @@ -0,0 +1,223 @@ +"""The community-station providers: relays, status documents and schedules.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import httpx +import pytest + +from beefy_player.providers import PROVIDERS +from beefy_player.providers.community import ( + AnonradioProvider, + CashmereProvider, + DatafruitsProvider, + DublabProvider, + LotProvider, + LylProvider, + RefugeProvider, + RinseProvider, + dublab, +) + +ICECAST = { + "icestats": { + "source": [ + { + "listenurl": "https://anonradio.net:8443/anonradio", + "bitrate": 192, + "server_type": "audio/mpeg", + "listeners": 13, + "title": "repeating screwtape from 29-Nov-2023", + } + ] + } +} + +RADIO_CO = { + "status": "online", + "listeners": 41, + "current_track": { + "title": "Guest Show (r) - Ain Autavg", + "start_time": "2026-11-04T00:00:02+00:00", + "artwork_url": "https://images.radio.co/station.png", + }, +} + + +def client_for(payload, status_code: int = 200) -> httpx.AsyncClient: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(status_code, json=payload) + + return httpx.AsyncClient(transport=httpx.MockTransport(handler)) + + +def test_every_community_provider_builds_a_playable_station() -> None: + providers = ( + AnonradioProvider, + DatafruitsProvider, + CashmereProvider, + LylProvider, + LotProvider, + RefugeProvider, + DublabProvider, + RinseProvider, + ) + for cls in providers: + provider = cls() + stations = provider.fallback_stations() + assert stations, cls.id + for station in stations: + assert station.provider == provider.id + assert station.playlists, (cls.id, station.id) + assert all(url.startswith(("http://", "https://")) for url in provider.fallback_urls(station)) + # every one of them spells its name in the same font + assert len(provider.hero) == 3, cls.id + + +def test_rinse_is_a_family_of_three_stations() -> None: + stations = RinseProvider().fallback_stations() + assert [station.id for station in stations] == ["rinse_uk", "kool", "swu"] + assert [station.title for station in stations] == ["Rinse UK", "Kool London", "SWU"] + for station in stations: + assert "admin.stream.rinse.fm/proxy/" in station.playlists[0]["url"] + assert station.format_label.endswith("128k") + + +def test_the_lot_streams_hls_which_the_player_owns() -> None: + from beefy_player.providers.base import is_direct_stream, is_playlist + + url = LotProvider().fallback_urls(LotProvider().station())[0] + assert url.endswith(".m3u8") + assert is_playlist(url) is False + assert is_direct_stream(url) is True + + +@pytest.mark.asyncio +async def test_icecast_status_gives_the_station_what_is_on() -> None: + provider = AnonradioProvider() + async with client_for(ICECAST) as client: + station = (await provider.fetch_stations(client))[0] + assert station.listeners == 13 + assert station.now_playing is not None + assert station.now_playing.title == "repeating screwtape from 29-Nov-2023" + + +@pytest.mark.asyncio +async def test_radio_co_status_gives_a_track_and_its_art() -> None: + provider = RefugeProvider() + async with client_for(RADIO_CO) as client: + station = (await provider.fetch_stations(client))[0] + history = await provider.fetch_history(client, "refuge") + assert station.listeners == 41 + assert station.now_playing is not None + assert station.now_playing.album == "" + assert station.now_playing.art_url.endswith("station.png") + assert history[0].title == "Ain Autavg" + # the show's own start time is used, not "just now" + assert history[0].played_at == datetime( + 2026, 11, 4, 0, 0, 2, tzinfo=timezone.utc + ) + + +@pytest.mark.asyncio +async def test_history_is_rolling_and_keeps_the_first_sighting() -> None: + provider = AnonradioProvider() + async with client_for(ICECAST) as client: + first = await provider.fetch_history(client, "anonradio") + again = await provider.fetch_history(client, "anonradio") + assert len(first) == 1 + assert again[0].played_at == first[0].played_at + + +@pytest.mark.asyncio +async def test_a_station_without_metadata_still_carries_on() -> None: + """Rinse, LYL, The Lot and Cashmere publish no status document.""" + provider = RinseProvider() + async with client_for({}, status_code=500) as client: + stations = await provider.fetch_stations(client) + history = await provider.fetch_history(client, "rinse_uk") + assert len(stations) == 3 + assert all(station.now_playing is None for station in stations) + assert history == [] + + +def test_dublab_schedule_parses_the_site_calendar() -> None: + payload = [ + { + "startTime": "2026-11-03T16:00:00-08:00", + "endTime": "2026-11-03T18:00:00-08:00", + "summary": "LIVE // Version Sounds", + }, + { + "startTime": "2026-11-03T18:00:00-08:00", + "endTime": "2026-11-03T20:00:00-08:00", + "summary": "PRE-REC // Sounds of Now", + }, + ] + shows = dublab.parse_schedule(payload) + assert [show.name for show in shows] == [ + "LIVE // Version Sounds", + "PRE-REC // Sounds of Now", + ] + assert shows[0].start == datetime( + 2026, 11, 3, 16, tzinfo=timezone(timedelta(hours=-8)) + ) + assert dublab.parse_schedule({"nope": True}) == [] + + +def test_the_running_slot_is_marked_live() -> None: + provider = DublabProvider() + now = datetime.now(timezone.utc) + running = dublab.Broadcast( + name="LIVE // Version Sounds", + start=now - timedelta(minutes=30), + end=now + timedelta(minutes=30), + ) + later = dublab.Broadcast( + name="Later Show", + start=now + timedelta(hours=1), + end=now + timedelta(hours=2), + ) + marked = provider._mark_live([running, later]) + assert [item.live for item in marked] == [True, False] + assert marked[0].key() == running.key() + + +@pytest.mark.asyncio +async def test_dublab_history_follows_the_schedule() -> None: + provider = DublabProvider() + now = datetime.now(timezone.utc) + payload = [ + { + "startTime": (now - timedelta(minutes=20)).isoformat(), + "endTime": (now + timedelta(minutes=40)).isoformat(), + "summary": "LIVE // Version Sounds", + } + ] + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=payload) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + await provider.fetch_schedule(client) + history = await provider.fetch_history(client, "dublab") + assert history and history[0].title == "LIVE // Version Sounds" + + +def test_every_catalogue_is_registered_under_its_slug() -> None: + assert set(PROVIDERS) == { + "somafm", + "tilderadio", + "bassdrive", + "radioparadise", + "dublab", + "rinse", + "refuge", + "lyl", + "lot", + "radiobrowser", + "cashmere", + "datafruits", + "anonradio", + } diff --git a/tests/test_providers.py b/tests/test_providers.py index f11b32e..0188d15 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -22,7 +22,22 @@ def rendered_text(app) -> str: def test_registry_lists_somafm_as_the_default() -> None: - assert available_providers() == ("somafm", "tilderadio", "bassdrive") + # the menu is alphabetical by name: aNONradio ... tilderadio + assert available_providers() == ( + "anonradio", + "bassdrive", + "cashmere", + "datafruits", + "dublab", + "lyl", + "radiobrowser", + "radioparadise", + "refuge", + "rinse", + "somafm", + "lot", + "tilderadio", + ) assert isinstance(get_provider(), SomaFMProvider) assert get_provider("somafm") is get_provider() assert isinstance(get_provider("tilderadio"), TilderadioProvider) @@ -177,7 +192,13 @@ async def test_p_opens_the_picker_and_switches_catalogue() -> None: await pilot.pause() assert isinstance(app.screen, ProviderScreen) - await pilot.press("2") # jump straight to tilderadio + # walk down the alphabetical menu from the current entry to tilderadio + slugs = available_providers() + steps = (slugs.index("tilderadio") - slugs.index("somafm")) % len(slugs) + for _ in range(steps): + await pilot.press("down") + await pilot.pause() + await pilot.press("enter") await pilot.pause() await pilot.pause() diff --git a/tests/test_radiobrowser.py b/tests/test_radiobrowser.py new file mode 100644 index 0000000..7e602c9 --- /dev/null +++ b/tests/test_radiobrowser.py @@ -0,0 +1,113 @@ +"""Radio Browser: a directory of stations, not a station.""" + +from __future__ import annotations + +import httpx +import pytest + +from beefy_player.providers.radiobrowser import RadioBrowserProvider, api, fallback + +ENTRY = { + "stationuuid": "96062a7b-0601-11e8-ae97-52543be04c81", + "name": "France Info", + "url_resolved": "http://direct.franceinfo.fr/live/franceinfo-midfi.mp3", + "url": "http://direct.franceinfo.fr/live/franceinfo-midfi.mp3", + "country": "France", + "language": "french", + "tags": "news,talk,politic", + "codec": "MP3", + "bitrate": 128, + "favicon": "https://www.francetvinfo.fr/icon.png", +} + + +def test_an_entry_becomes_a_station() -> None: + station = api.station_from_entry(ENTRY) + assert station is not None + assert station.id == ENTRY["stationuuid"] + assert station.title == "France Info" + assert station.description == "France · french" + assert station.genre_label == "news · talk · politic" + assert station.image.endswith("icon.png") + assert station.format_label == "mp3 · 128k" + assert station.provider == "radiobrowser" + + +def test_entries_without_a_stream_or_a_name_are_skipped() -> None: + assert api.station_from_entry({**ENTRY, "url_resolved": "", "url": ""}) is None + assert api.station_from_entry({**ENTRY, "name": " "}) is None + assert api.station_from_entry({**ENTRY, "stationuuid": ""}) is None + assert api.station_from_entry("not a dict") is None + + +def test_parse_dedupes_and_sorts_by_name() -> None: + payload = [ + {**ENTRY, "stationuuid": "b", "name": "Zed FM"}, + {**ENTRY, "stationuuid": "a", "name": "Alpha Radio"}, + {**ENTRY, "stationuuid": "a", "name": "Alpha Radio"}, + ] + stations = api.parse_stations(payload) + assert [station.title for station in stations] == ["Alpha Radio", "Zed FM"] + assert api.parse_stations(None) == [] + assert api.parse_stations({}) == [] + + +def test_hls_streams_survive_as_direct_urls() -> None: + hls = { + **ENTRY, + "stationuuid": "hls", + "url_resolved": "https://example.test/live/index.m3u8?token=1", + "codec": "AAC", + "bitrate": 64, + } + station = api.station_from_entry(hls) + assert station is not None + assert station.playlists[0]["format"] == "aac" + assert station.format_label == "aac · 64k" + + +@pytest.mark.asyncio +async def test_fetch_stations_caches_the_directory() -> None: + payload = [ENTRY, {**ENTRY, "stationuuid": "x", "name": "Another"}] + + def handler(request: httpx.Request) -> httpx.Response: + assert "hidebroken=true" in str(request.url) + assert "limit=300" in str(request.url) + return httpx.Response(200, json=payload) + + provider = RadioBrowserProvider() + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + stations = await provider.fetch_stations(client) + assert [station.title for station in stations] == ["Another", "France Info"] + assert [station.title for station in provider.load_cached_stations()] == [ + "Another", + "France Info", + ] + + +@pytest.mark.asyncio +async def test_a_failed_fetch_does_not_kill_the_catalogue() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(500, json={}) + + provider = RadioBrowserProvider() + with pytest.raises(RuntimeError): + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + await provider.fetch_stations(client) + + +def test_the_bundled_directory_works_offline() -> None: + providers = fallback.fallback_stations() + assert len(providers) == 10 + for station in providers: + assert station.playlists and station.provider == "radiobrowser" + assert station.playlists[0]["url"].startswith("https://") + + +def test_the_directory_offers_no_history_and_no_schedule() -> None: + provider = RadioBrowserProvider() + assert provider.has_schedule is False + assert provider.bandcamp is False + assert len(provider.hero) == 3 + assert provider.hero_lines[0] == "RADIO BROWSER" + assert provider.load_cached_history("anything") == [] diff --git a/tests/test_radioparadise.py b/tests/test_radioparadise.py new file mode 100644 index 0000000..e217782 --- /dev/null +++ b/tests/test_radioparadise.py @@ -0,0 +1,90 @@ +"""Radio Paradise: five mixes, one now-playing API, rolling history.""" + +from __future__ import annotations + +import httpx +import pytest + +from beefy_player.providers.radioparadise import RadioParadiseProvider, api + +NOW = { + "time": 244, + "artist": "Eric Bibb", + "title": "Don't Ever Let Nobody Drag Your Spirit Down", + "album": "Painting Signs", + "year": "1999", + "cover": "https://img.radioparadise.com/covers/l/1.jpg", +} + + +def test_parse_now_keeps_the_cover_art() -> None: + song = api.parse_now(NOW) + assert song is not None + assert (song.artist, song.title) == ("Eric Bibb", NOW["title"]) + assert song.album == "Painting Signs" + assert song.art_url.endswith("1.jpg") + assert api.parse_now({}) is None + assert api.parse_now(None) is None + + +def test_playlists_prefer_flac_and_carry_real_bitrates() -> None: + playlists = api.playlists(("flac", "aac-320", "mp3-320", "mp3-128")) + assert [entry["url"].rsplit("/", 1)[1] for entry in playlists] == [ + "flac", + "aac-320", + "mp3-320", + "mp3-128", + ] + assert [entry["quality"] for entry in playlists] == [ + "highest", + "high", + "high", + "high", + ] + assert playlists[0]["bitrate"] == "1411" + assert playlists[-1]["bitrate"] == "128" + + +def test_five_mixes_with_channel_numbers() -> None: + provider = RadioParadiseProvider() + stations = provider.fallback_stations() + assert [station.id for station in stations] == [ + "main", + "mellow", + "rock", + "global", + "beyond", + ] + assert api.CHANNEL_BY_STATION["beyond"] == 5 + assert stations[0].format_label.startswith("flac") + # the alternates are FLAC-only relays + assert [entry["url"].rsplit("/", 1)[1] for entry in stations[1].playlists] == [ + "mellow-flac" + ] + + +@pytest.mark.asyncio +async def test_history_polls_the_right_channel_and_rolls() -> None: + seen: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(str(request.url)) + return httpx.Response(200, json=NOW) + + provider = RadioParadiseProvider() + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + first = await provider.fetch_history(client, "mellow") + again = await provider.fetch_history(client, "mellow") + + assert any("chan=1" in url for url in seen) + assert len(first) == 1 + assert again[0].key() == first[0].key() + assert again[0].played_at == first[0].played_at # first sighting kept + assert provider.load_cached_stations()[0].title == "The Main Mix" + + +@pytest.mark.asyncio +async def test_an_unknown_station_has_no_history() -> None: + provider = RadioParadiseProvider() + async with httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, json=NOW))) as client: + assert await provider.fetch_history(client, "no-such-mix") == []