Compare commits
2
Commits
a3ad693132
...
0219df454c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0219df454c | ||
|
|
49f7efe0d2 |
No files matched your search
@@ -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
|
own ANSI palette on the terminal's default background so it fits any theme
|
||||||
(including transparent terminals).
|
(including transparent terminals).
|
||||||
|
|
||||||
The station catalogue is pluggable. Beefy Player ships with
|
The station catalogue is pluggable. Beefy Player ships with thirteen catalogues
|
||||||
**[SomaFM](https://somafm.com)** (≈50 curated channels),
|
— **[SomaFM](https://somafm.com)** (≈50 curated channels),
|
||||||
**[tilderadio](https://tilderadio.org)** (the tildeverse's single community
|
**[Radio Paradise](https://radioparadise.com)** (five mixes), the community
|
||||||
stream) and **[Bassdrive](https://bassdrive.com)** (worldwide drum & bass), and
|
stations **[tilderadio](https://tilderadio.org)**,
|
||||||
the UI never talks to a station catalogue directly: it asks a
|
**[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
|
[provider](#providers) for stations, song history and stream URLs. Press `p` to
|
||||||
switch catalogue without restarting. Adding another internet-radio catalogue is
|
switch catalogue without restarting. Adding another internet-radio catalogue is
|
||||||
a self-contained module, not a rewrite.
|
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.
|
- **Last played** — the full recent history, newest first.
|
||||||
- **Every station** — with SomaFM that is the whole `channels.json` roster
|
- **Every station** — with SomaFM that is the whole `channels.json` roster
|
||||||
(≈50 channels), live listener counts and genre search; tilderadio is a
|
(≈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.
|
- **Favourites** that float to the top, remembered between runs.
|
||||||
- **Pluggable catalogues** — one `providers` module per radio network, with a
|
- **Pluggable catalogues** — one `providers` module per radio network, with a
|
||||||
`p` picker in the UI, `--provider` on the command line, and the choice
|
`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
|
`beefy_player/providers/somafm/`. Nothing outside that package imports it; the
|
||||||
app only ever holds a `RadioProvider`.
|
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/<station>.json` | `.pls` → icecast mirrors |
|
| `somafm` | [SomaFM](https://somafm.com) | `channels.json` (≈50) | `songs/<station>.json` | `.pls` → icecast mirrors |
|
||||||
| `tilderadio` | [tilderadio](https://tilderadio.org) | `/api/now/` (1) | `now_playing` + `history[]` | AzuraCast mounts (mp3/ogg, 320k) |
|
| `radioparadise` | [Radio Paradise](https://radioparadise.com) | five mixes | `api/now_playing` (artist, album, cover) | FLAC / AAC / MP3 icecast |
|
||||||
| `bassdrive` | [Bassdrive](https://bassdrive.com) | icecast status (1) | rolling `display-title` | three mounts + mirrors |
|
| `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
|
They are deliberately different shapes: fifty channels with PLS playlists, a
|
||||||
single AzuraCast stream that carries its own history, and an Icecast relay whose
|
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.
|
"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
|
The single-stream catalogues also use the optional capabilities: where a
|
||||||
pane is the broadcast schedule rather than a list containing one station, and
|
schedule exists (`tilderadio`, `bassdrive`, `dublab`) the left pane is that
|
||||||
they opt out of Bandcamp lookups. Bassdrive is the extreme case — Icecast
|
schedule rather than a list containing one station, and none of them do
|
||||||
reports one title and keeps no history, so the provider maps the title to
|
Bandcamp lookups. Bassdrive is the extreme case — Icecast reports one title and
|
||||||
`Station.dj`/`presenter` and keeps a rolling record of the shows it has heard.
|
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
|
### Header wordmarks
|
||||||
|
|
||||||
@@ -306,6 +337,7 @@ The UI wants roughly **84×24**; it also works smaller, just less pretty.
|
|||||||
| key | action |
|
| key | action |
|
||||||
| -------------- | --------------------------------------------- |
|
| -------------- | --------------------------------------------- |
|
||||||
| `↑` / `↓`, `j` / `k` | move through stations |
|
| `↑` / `↓`, `j` / `k` | move through stations |
|
||||||
|
| `pgup` / `pgdn` | page through stations |
|
||||||
| `enter` | tune in to the selected station |
|
| `enter` | tune in to the selected station |
|
||||||
| `space` | play / pause |
|
| `space` | play / pause |
|
||||||
| `s` | stop |
|
| `s` | stop |
|
||||||
@@ -363,8 +395,11 @@ beefy_player/
|
|||||||
base.py RadioProvider interface + HTTP/playlist toolkit
|
base.py RadioProvider interface + HTTP/playlist toolkit
|
||||||
wordmark.py the half-block font every hero is drawn with
|
wordmark.py the half-block font every hero is drawn with
|
||||||
somafm/ the SomaFM catalogue (API, hero, bundled fallback list)
|
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)
|
tilderadio/ the tilderadio catalogue (one station, hero, schedule)
|
||||||
bassdrive/ Bassdrive (Icecast status, show schedule, rolling history)
|
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
|
player.py mpv over a Unix-socket JSON IPC channel
|
||||||
spectrum.py analyser (parec / Core Audio tap / ffmpeg / sim)
|
spectrum.py analyser (parec / Core Audio tap / ffmpeg / sim)
|
||||||
macos_tap.py macOS output capture (ctypes + Core Audio)
|
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
|
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
|
download; the display-title's show is matched against the running slot for
|
||||||
"live from <city>".
|
"live from <city>".
|
||||||
|
- `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
|
- `beefy_player/providers/wordmark.py` — the half-block capitals behind every
|
||||||
hero, with per-character style spans.
|
hero, with per-character style spans.
|
||||||
- `beefy_player/widgets/schedule.py` — the left pane for single-stream
|
- `beefy_player/widgets/schedule.py` — the left pane for single-stream
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ class BeefyPlayerApp(App[None]):
|
|||||||
Binding("down,j", "cursor_down", "down", show=False),
|
Binding("down,j", "cursor_down", "down", show=False),
|
||||||
Binding("g", "cursor_top", "top", show=False),
|
Binding("g", "cursor_top", "top", show=False),
|
||||||
Binding("shift+g", "cursor_bottom", "bottom", show=False),
|
Binding("shift+g", "cursor_bottom", "bottom", show=False),
|
||||||
|
Binding("pageup", "page_up", "page up", show=False),
|
||||||
|
Binding("pagedown", "page_down", "page down", show=False),
|
||||||
Binding("enter", "tune", "tune in"),
|
Binding("enter", "tune", "tune in"),
|
||||||
Binding("space", "toggle_play", "play/pause"),
|
Binding("space", "toggle_play", "play/pause"),
|
||||||
Binding("s", "stop", "stop", show=False),
|
Binding("s", "stop", "stop", show=False),
|
||||||
@@ -824,6 +826,19 @@ class BeefyPlayerApp(App[None]):
|
|||||||
self.index = min(len(self.view) - 1, self.index + 1)
|
self.index = min(len(self.view) - 1, self.index + 1)
|
||||||
self.query_one(StationList).refresh()
|
self.query_one(StationList).refresh()
|
||||||
|
|
||||||
|
def _page(self, delta: int) -> None:
|
||||||
|
if not self.view:
|
||||||
|
return
|
||||||
|
page = max(1, self.query_one(StationList).size.height - 1)
|
||||||
|
self.index = max(0, min(len(self.view) - 1, self.index + delta * page))
|
||||||
|
self.query_one(StationList).refresh()
|
||||||
|
|
||||||
|
def action_page_up(self) -> None:
|
||||||
|
self._page(-1)
|
||||||
|
|
||||||
|
def action_page_down(self) -> None:
|
||||||
|
self._page(1)
|
||||||
|
|
||||||
def action_cursor_top(self) -> None:
|
def action_cursor_top(self) -> None:
|
||||||
if not self.view:
|
if not self.view:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from .base import RadioProvider
|
from .base import RadioProvider
|
||||||
from .bassdrive import BassdriveProvider
|
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 .somafm import SomaFMProvider
|
||||||
from .tilderadio import TilderadioProvider
|
from .tilderadio import TilderadioProvider
|
||||||
|
|
||||||
@@ -29,10 +41,28 @@ __all__ = [
|
|||||||
"get_provider",
|
"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] = {
|
PROVIDERS: dict[str, RadioProvider] = {
|
||||||
provider.id: provider
|
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.
|
#: Provider used when nothing else is configured.
|
||||||
|
|||||||
@@ -194,8 +194,9 @@ class RadioProvider(ABC):
|
|||||||
|
|
||||||
|
|
||||||
def is_playlist(url: str) -> bool:
|
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()
|
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:
|
def is_direct_stream(url: str) -> bool:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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")
|
||||||
@@ -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]
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
|
]
|
||||||
@@ -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()]
|
||||||
@@ -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")))
|
||||||
@@ -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
|
||||||
|
]
|
||||||
@@ -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")
|
||||||
@@ -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()]
|
||||||
@@ -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/<name>`` -> 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
|
||||||
@@ -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")
|
||||||
@@ -18,18 +18,27 @@ from collections.abc import Sequence
|
|||||||
GLYPHS: dict[str, tuple[str, ...]] = {
|
GLYPHS: dict[str, tuple[str, ...]] = {
|
||||||
"A": (".##.", "#..#", "####", "#..#", "#..#", "#..#"),
|
"A": (".##.", "#..#", "####", "#..#", "#..#", "#..#"),
|
||||||
"B": ("###.", "#..#", "###.", "#..#", "#..#", "###."),
|
"B": ("###.", "#..#", "###.", "#..#", "#..#", "###."),
|
||||||
|
"C": (".##.", "#..#", "#...", "#...", "#..#", ".##."),
|
||||||
"D": ("###.", "#..#", "#..#", "#..#", "#..#", "###."),
|
"D": ("###.", "#..#", "#..#", "#..#", "#..#", "###."),
|
||||||
"E": ("####", "#...", "###.", "#...", "#...", "####"),
|
"E": ("####", "#...", "###.", "#...", "#...", "####"),
|
||||||
"F": ("####", "#...", "###.", "#...", "#...", "#..."),
|
"F": ("####", "#...", "###.", "#...", "#...", "#..."),
|
||||||
|
"G": (".##.", "#..#", "#...", "#.##", "#..#", ".##."),
|
||||||
|
"H": ("#..#", "#..#", "####", "#..#", "#..#", "#..#"),
|
||||||
"I": ("###", ".#.", ".#.", ".#.", ".#.", "###"),
|
"I": ("###", ".#.", ".#.", ".#.", ".#.", "###"),
|
||||||
|
"K": ("#..#", "#.#.", "##..", "##..", "#.#.", "#..#"),
|
||||||
"L": ("#...", "#...", "#...", "#...", "#...", "####"),
|
"L": ("#...", "#...", "#...", "#...", "#...", "####"),
|
||||||
# 5 wide on purpose: the M is the odd one out in the original wordmark.
|
# 5 wide on purpose: the M is the odd one out in the original wordmark.
|
||||||
"M": ("#...#", "##.##", "#.#.#", "#.#.#", "#...#", "#...#"),
|
"M": ("#...#", "##.##", "#.#.#", "#.#.#", "#...#", "#...#"),
|
||||||
|
"N": ("#..#", "##.#", "##.#", "#.##", "#..#", "#..#"),
|
||||||
"O": (".##.", "#..#", "#..#", "#..#", "#..#", ".##."),
|
"O": (".##.", "#..#", "#..#", "#..#", "#..#", ".##."),
|
||||||
|
"P": ("###.", "#..#", "###.", "#...", "#...", "#..."),
|
||||||
"R": ("###.", "#..#", "###.", "#.#.", "#..#", "#..#"),
|
"R": ("###.", "#..#", "###.", "#.#.", "#..#", "#..#"),
|
||||||
"S": (".###", "#...", "###.", "...#", "...#", "###."),
|
"S": (".###", "#...", "###.", "...#", "...#", "###."),
|
||||||
"T": ("###", ".#.", ".#.", ".#.", ".#.", ".#."),
|
"T": ("###", ".#.", ".#.", ".#.", ".#.", ".#."),
|
||||||
|
"U": ("#..#", "#..#", "#..#", "#..#", "#..#", ".##."),
|
||||||
"V": ("#..#", "#..#", "#..#", "#..#", ".##.", ".##."),
|
"V": ("#..#", "#..#", "#..#", "#..#", ".##.", ".##."),
|
||||||
|
"W": ("#...#", "#...#", "#.#.#", "#.#.#", "##.##", "#...#"),
|
||||||
|
"Y": ("#..#", "#..#", ".##.", "..#.", "..#.", "..#."),
|
||||||
}
|
}
|
||||||
|
|
||||||
ROWS = 3
|
ROWS = 3
|
||||||
|
|||||||
+24
-11
@@ -76,9 +76,6 @@ StationList {
|
|||||||
color: ansi_default;
|
color: ansi_default;
|
||||||
width: 1fr;
|
width: 1fr;
|
||||||
height: 1fr;
|
height: 1fr;
|
||||||
border: round ansi_bright_black;
|
|
||||||
border-title-color: ansi_default;
|
|
||||||
padding: 0 1;
|
|
||||||
background: ansi_default;
|
background: ansi_default;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,9 +84,6 @@ SchedulePanel {
|
|||||||
width: 1fr;
|
width: 1fr;
|
||||||
height: 1fr;
|
height: 1fr;
|
||||||
display: none;
|
display: none;
|
||||||
border: round ansi_bright_black;
|
|
||||||
border-title-color: ansi_default;
|
|
||||||
padding: 0 1;
|
|
||||||
background: ansi_default;
|
background: ansi_default;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,8 +91,30 @@ StagePanel {
|
|||||||
color: ansi_default;
|
color: ansi_default;
|
||||||
layout: vertical;
|
layout: vertical;
|
||||||
width: 1fr;
|
width: 1fr;
|
||||||
border: round ansi_bright_black;
|
background: ansi_default;
|
||||||
border-title-color: ansi_default;
|
}
|
||||||
|
|
||||||
|
#stage_frame_top,
|
||||||
|
#stage_frame_bottom {
|
||||||
|
height: 1;
|
||||||
|
background: ansi_default;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stage_middle {
|
||||||
|
height: 1fr;
|
||||||
|
background: ansi_default;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stage_frame_left,
|
||||||
|
#stage_frame_right {
|
||||||
|
width: 1;
|
||||||
|
height: 1fr;
|
||||||
|
background: ansi_default;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stage_body {
|
||||||
|
width: 1fr;
|
||||||
|
height: 1fr;
|
||||||
padding: 0 1;
|
padding: 0 1;
|
||||||
background: ansi_default;
|
background: ansi_default;
|
||||||
}
|
}
|
||||||
@@ -143,9 +159,6 @@ SpectrumPanel {
|
|||||||
color: ansi_default;
|
color: ansi_default;
|
||||||
height: 2fr;
|
height: 2fr;
|
||||||
min-height: 6;
|
min-height: 6;
|
||||||
border: round ansi_bright_black;
|
|
||||||
border-title-color: ansi_default;
|
|
||||||
padding: 0 1;
|
|
||||||
background: ansi_default;
|
background: ansi_default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""Pane frames: one grey box, with two corners fading out of it.
|
||||||
|
|
||||||
|
The panes draw their own frames instead of using Textual's ``border`` so the
|
||||||
|
corners can be coloured individually. Two of them — the top right and the
|
||||||
|
bottom left — run white → light grey → bright cyan → cyan along both arms;
|
||||||
|
everything else, including the other two corners, is the border grey.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from rich.cells import cell_len
|
||||||
|
from rich.console import Group, RenderableType
|
||||||
|
from rich.text import Text
|
||||||
|
from textual.widget import Widget
|
||||||
|
|
||||||
|
#: The frame itself: one grey, the same grey as the scrollbar thumb.
|
||||||
|
EDGE = "bright_black"
|
||||||
|
|
||||||
|
#: A faded corner runs white → light grey → bright cyan → cyan, then settles
|
||||||
|
#: back into the border grey.
|
||||||
|
CORNER_GRADIENT = ("bright_white", "white", "bright_cyan", "cyan")
|
||||||
|
|
||||||
|
|
||||||
|
def fade(distance: int) -> str:
|
||||||
|
"""Colour ``distance`` cells away from a corner (0 is the corner itself)."""
|
||||||
|
if 0 <= distance < len(CORNER_GRADIENT):
|
||||||
|
return CORNER_GRADIENT[distance]
|
||||||
|
return EDGE
|
||||||
|
|
||||||
|
|
||||||
|
def side_style(row: int, rows: int, side: str = "left") -> str:
|
||||||
|
"""Colour of a side cell: only two corners fade, so only one end glows."""
|
||||||
|
if side == "right":
|
||||||
|
return fade(row + 1) # fades down from the top-right corner
|
||||||
|
return fade(rows - row) # fades up from the bottom-left corner
|
||||||
|
|
||||||
|
|
||||||
|
def inner_size(width: int, height: int) -> tuple[int, int]:
|
||||||
|
"""The content box inside the frame."""
|
||||||
|
return max(1, width - 2), max(1, height - 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _edge_cells(width: int, faded_end: str = "right") -> list[tuple[str, str]]:
|
||||||
|
"""Inner cells of an edge; only one end of it fades into a corner."""
|
||||||
|
inner = max(0, width - 2)
|
||||||
|
if faded_end == "left":
|
||||||
|
return [("─", fade(column + 1)) for column in range(inner)]
|
||||||
|
return [("─", fade(inner - column)) for column in range(inner)]
|
||||||
|
|
||||||
|
|
||||||
|
def _paint(line: Text, cells: list[tuple[str, str]]) -> None:
|
||||||
|
"""Append cells, grouping neighbours that share a style into one span."""
|
||||||
|
style: str | None = None
|
||||||
|
run: list[str] = []
|
||||||
|
for char, cell_style in cells:
|
||||||
|
if cell_style != style:
|
||||||
|
if run:
|
||||||
|
line.append("".join(run), style=style)
|
||||||
|
run, style = [], cell_style
|
||||||
|
run.append(char)
|
||||||
|
if run:
|
||||||
|
line.append("".join(run), style=style)
|
||||||
|
|
||||||
|
|
||||||
|
def top(width: int, title: str = "") -> Text:
|
||||||
|
width = max(2, width)
|
||||||
|
cells = _edge_cells(width, "right") # only the top-right corner fades
|
||||||
|
if title:
|
||||||
|
label = f" {title} "
|
||||||
|
start = 1
|
||||||
|
if start + cell_len(label) <= len(cells):
|
||||||
|
for offset, char in enumerate(label):
|
||||||
|
cells[start + offset] = (char, "bold")
|
||||||
|
|
||||||
|
line = Text(no_wrap=True)
|
||||||
|
line.append("╭", style=EDGE)
|
||||||
|
_paint(line, cells)
|
||||||
|
line.append("╮", style=f"bold {fade(0)}")
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
def bottom(width: int) -> Text:
|
||||||
|
width = max(2, width)
|
||||||
|
line = Text(no_wrap=True)
|
||||||
|
line.append("╰", style=f"bold {fade(0)}")
|
||||||
|
_paint(line, _edge_cells(width, "left")) # only the bottom-left fades
|
||||||
|
line.append("╯", style=EDGE)
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
def side() -> Text:
|
||||||
|
return Text("│", style=EDGE)
|
||||||
|
|
||||||
|
|
||||||
|
def side_row(row: int, rows: int, side: str = "left") -> Text:
|
||||||
|
return Text("│", style=side_style(row, rows, side))
|
||||||
|
|
||||||
|
|
||||||
|
def wrap(
|
||||||
|
lines: list[Text], width: int, height: int, title: str = ""
|
||||||
|
) -> RenderableType:
|
||||||
|
"""Frame ``lines`` (already sized for the content box) in a pane."""
|
||||||
|
width = max(4, width)
|
||||||
|
height = max(2, height)
|
||||||
|
inner_width, inner_height = inner_size(width, height)
|
||||||
|
rows = len(lines[:inner_height])
|
||||||
|
|
||||||
|
framed: list[Text] = [top(width, title)]
|
||||||
|
for index, line in enumerate(lines[:inner_height]):
|
||||||
|
row = Text(no_wrap=True)
|
||||||
|
row.append_text(side_row(index, rows, "left"))
|
||||||
|
row.append_text(line)
|
||||||
|
pad = inner_width - cell_len(line.plain)
|
||||||
|
if pad > 0:
|
||||||
|
row.append(" " * pad)
|
||||||
|
row.append_text(side_row(index, rows, "right"))
|
||||||
|
framed.append(row)
|
||||||
|
while len(framed) < height - 1:
|
||||||
|
index = len(framed) - 1
|
||||||
|
row = Text(no_wrap=True)
|
||||||
|
row.append_text(side_row(index, inner_height, "left"))
|
||||||
|
row.append(" " * inner_width)
|
||||||
|
row.append_text(side_row(index, inner_height, "right"))
|
||||||
|
framed.append(row)
|
||||||
|
framed.append(bottom(width))
|
||||||
|
return Group(*framed[:height])
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# frame pieces, for containers that cannot draw their own content
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
class FrameTop(Widget):
|
||||||
|
can_focus = False
|
||||||
|
|
||||||
|
def __init__(self, title: str = "", *, id: str | None = None) -> None:
|
||||||
|
super().__init__(id=id)
|
||||||
|
self.title = title
|
||||||
|
|
||||||
|
def render(self) -> RenderableType:
|
||||||
|
return top(max(2, self.size.width), self.title)
|
||||||
|
|
||||||
|
|
||||||
|
class FrameBottom(Widget):
|
||||||
|
can_focus = False
|
||||||
|
|
||||||
|
def render(self) -> RenderableType:
|
||||||
|
return bottom(max(2, self.size.width))
|
||||||
|
|
||||||
|
|
||||||
|
class FrameSide(Widget):
|
||||||
|
can_focus = False
|
||||||
|
|
||||||
|
def __init__(self, side: str = "left", *, id: str | None = None) -> None:
|
||||||
|
super().__init__(id=id)
|
||||||
|
self.side = side
|
||||||
|
|
||||||
|
def render(self) -> RenderableType:
|
||||||
|
rows = max(1, self.size.height)
|
||||||
|
return Group(
|
||||||
|
*[side_row(row, rows, self.side) for row in range(rows)]
|
||||||
|
)
|
||||||
@@ -74,6 +74,11 @@ class HeaderBar(Widget):
|
|||||||
lines: list[Text] = []
|
lines: list[Text] = []
|
||||||
for index, row in enumerate(hero):
|
for index, row in enumerate(hero):
|
||||||
line = _hero_line(row, index, hero_width, gradient_fill)
|
line = _hero_line(row, index, hero_width, gradient_fill)
|
||||||
|
# Art rows are right-trimmed, so a row ending in a blank cell is a
|
||||||
|
# column short; pad it back or the caption and clock drift left.
|
||||||
|
short = hero_width - cell_len(line.plain)
|
||||||
|
if short > 0:
|
||||||
|
line.append(" " * short)
|
||||||
if show_middle and text_offset <= index < text_offset + len(hero_lines):
|
if show_middle and text_offset <= index < text_offset + len(hero_lines):
|
||||||
middle = hero_lines[index - text_offset]
|
middle = hero_lines[index - text_offset]
|
||||||
line.append(" " * 4)
|
line.append(" " * 4)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
HELP_ROWS: tuple[tuple[str, str], ...] = (
|
HELP_ROWS: tuple[tuple[str, str], ...] = (
|
||||||
("↑ / ↓ j / k", "move through the station list"),
|
("↑ / ↓ j / k", "move through the station list"),
|
||||||
|
("pgup / pgdn", "page through the station list"),
|
||||||
("enter", "tune in to the selected station"),
|
("enter", "tune in to the selected station"),
|
||||||
("space", "play / pause the current station"),
|
("space", "play / pause the current station"),
|
||||||
("s", "stop playback"),
|
("s", "stop playback"),
|
||||||
|
|||||||
@@ -30,24 +30,43 @@ class ProviderScreen(ModalScreen[str | None]):
|
|||||||
header.append("CATALOGUES", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}")
|
header.append("CATALOGUES", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}")
|
||||||
header.append(" · pick a station catalogue", style="dim")
|
header.append(" · pick a station catalogue", style="dim")
|
||||||
|
|
||||||
|
name_width = (
|
||||||
|
max(
|
||||||
|
(
|
||||||
|
len(PROVIDERS[slug].name)
|
||||||
|
for slug in self.slugs
|
||||||
|
if slug in PROVIDERS
|
||||||
|
),
|
||||||
|
default=12,
|
||||||
|
)
|
||||||
|
+ 2
|
||||||
|
)
|
||||||
|
|
||||||
rows: list[Text] = []
|
rows: list[Text] = []
|
||||||
for index, slug in enumerate(self.slugs):
|
for index, slug in enumerate(self.slugs):
|
||||||
provider = PROVIDERS[slug]
|
provider = PROVIDERS[slug]
|
||||||
selected = index == self.index
|
selected = index == self.index
|
||||||
row = Text(no_wrap=True)
|
row = Text(no_wrap=True)
|
||||||
row.append("▶ " if selected else " ", style="bold magenta" if selected else "")
|
row.append(
|
||||||
row.append(f"{index + 1} ", style="dim")
|
"▶ " if selected else " ", style="bold magenta" if selected else ""
|
||||||
name_style = "bold underline" if selected else "bold"
|
)
|
||||||
row.append(f"{provider.name:<12}", style=name_style)
|
row.append(f"{index + 1:>2} ", style="dim")
|
||||||
row.append(f" {provider.brand}", style="dim")
|
row.append(
|
||||||
|
f"{truncate(provider.name, name_width - 2):<{name_width}}",
|
||||||
|
style="bold underline" if selected else "bold",
|
||||||
|
)
|
||||||
|
row.append(truncate(provider.brand, 14), style="dim")
|
||||||
if slug == self.current_id:
|
if slug == self.current_id:
|
||||||
row.append(" ●", style="bold green")
|
row.append(" ●", style="bold green")
|
||||||
rows.append(row)
|
rows.append(row)
|
||||||
|
if selected:
|
||||||
detail = Text(no_wrap=True)
|
rows.append(
|
||||||
detail.append(" ", style="")
|
Text(
|
||||||
detail.append(truncate(provider.tagline, 54), style="italic dim")
|
" " + truncate(provider.tagline, 54),
|
||||||
rows.append(detail)
|
style="italic dim",
|
||||||
|
no_wrap=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
current = PROVIDERS.get(self.current_id)
|
current = PROVIDERS.get(self.current_id)
|
||||||
footer = Text()
|
footer = Text()
|
||||||
@@ -57,7 +76,7 @@ class ProviderScreen(ModalScreen[str | None]):
|
|||||||
f"now: {current.name} — {current.station_source}\n", style="dim"
|
f"now: {current.name} — {current.station_source}\n", style="dim"
|
||||||
)
|
)
|
||||||
footer.append(
|
footer.append(
|
||||||
"↑ ↓ choose · 1–9 jump · enter switch · esc cancel",
|
"↑ ↓ choose · enter switch · esc cancel",
|
||||||
style="dim",
|
style="dim",
|
||||||
)
|
)
|
||||||
return Group(header, Text(""), *rows, footer)
|
return Group(header, Text(""), *rows, footer)
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ from __future__ import annotations
|
|||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
from rich.console import Group, RenderableType
|
from rich.console import RenderableType
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
|
|
||||||
|
from . import frame
|
||||||
from .common import local_time, local_zone, state_glyph, truncate
|
from .common import local_time, local_zone, state_glyph, truncate
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -92,7 +93,8 @@ class SchedulePanel(Widget):
|
|||||||
def render(self) -> RenderableType:
|
def render(self) -> RenderableType:
|
||||||
app = cast("BeefyPlayerApp", self.app)
|
app = cast("BeefyPlayerApp", self.app)
|
||||||
width = max(12, self.size.width)
|
width = max(12, self.size.width)
|
||||||
height = max(1, self.size.height)
|
height = max(3, self.size.height)
|
||||||
|
width, height = frame.inner_size(width, height)
|
||||||
|
|
||||||
broadcasts = list(getattr(app, "schedule", ()) or ())
|
broadcasts = list(getattr(app, "schedule", ()) or ())
|
||||||
loaded = bool(getattr(app, "schedule_loaded", False))
|
loaded = bool(getattr(app, "schedule_loaded", False))
|
||||||
@@ -134,4 +136,9 @@ class SchedulePanel(Widget):
|
|||||||
lines.append(Text(""))
|
lines.append(Text(""))
|
||||||
while len(lines) < height:
|
while len(lines) < height:
|
||||||
lines.append(Text(""))
|
lines.append(Text(""))
|
||||||
return Group(*lines[:height])
|
return frame.wrap(
|
||||||
|
lines[:height],
|
||||||
|
max(12, self.size.width),
|
||||||
|
max(3, self.size.height),
|
||||||
|
self.border_title,
|
||||||
|
)
|
||||||
@@ -7,11 +7,12 @@ from collections import deque
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
from rich.console import Group, RenderableType
|
from rich.console import RenderableType
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
|
|
||||||
from ..spectrum import Frame, SpectrumEngine
|
from ..spectrum import Frame, SpectrumEngine
|
||||||
|
from . import frame
|
||||||
from .common import LEVELS, SPECTRUM_STOPS, gradient
|
from .common import LEVELS, SPECTRUM_STOPS, gradient
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -63,38 +64,40 @@ class SpectrumPanel(Widget):
|
|||||||
|
|
||||||
def render(self) -> RenderableType:
|
def render(self) -> RenderableType:
|
||||||
app = cast("BeefyPlayerApp", self.app)
|
app = cast("BeefyPlayerApp", self.app)
|
||||||
frame = self.engine.snapshot()
|
snapshot = self.engine.snapshot()
|
||||||
width = max(8, self.size.width)
|
outer_width, outer_height = max(12, self.size.width), max(4, self.size.height)
|
||||||
height = max(2, self.size.height)
|
width, height = frame.inner_size(outer_width, outer_height)
|
||||||
mode = getattr(app, "visualizer", "both")
|
mode = getattr(app, "visualizer", "both")
|
||||||
phase = int(getattr(app, "tick_count", 0) // 2)
|
phase = int(getattr(app, "tick_count", 0) // 2)
|
||||||
dim = frame.status == "idle"
|
dim = snapshot.status == "idle"
|
||||||
self._record(frame, int(getattr(app, "tick_count", 0)))
|
self._record(snapshot, int(getattr(app, "tick_count", 0)))
|
||||||
|
|
||||||
lines: list[Text]
|
lines: list[Text]
|
||||||
if mode == "wave":
|
if mode == "wave":
|
||||||
lines = self._wave(frame, width, height, phase, dim)
|
lines = self._wave(snapshot, width, height, phase, dim)
|
||||||
elif mode == "braille":
|
elif mode == "braille":
|
||||||
lines = self._braille(frame, width, height, phase, dim)
|
lines = self._braille(snapshot, width, height, phase, dim)
|
||||||
elif mode == "waterfall":
|
elif mode == "waterfall":
|
||||||
lines = self._waterfall(width, height, dim)
|
lines = self._waterfall(width, height, dim)
|
||||||
elif mode == "radial":
|
elif mode == "radial":
|
||||||
lines = self._radial(frame, width, height, phase, dim)
|
lines = self._radial(snapshot, width, height, phase, dim)
|
||||||
elif mode == "bars" or height < 8:
|
elif mode == "bars" or height < 8:
|
||||||
lines = self._bars(frame, width, height, phase, dim)
|
lines = self._bars(snapshot, width, height, phase, dim)
|
||||||
else:
|
else:
|
||||||
# Default mode: a full-height mirrored spectrum. The top half
|
# Default mode: a full-height mirrored spectrum. The top half
|
||||||
# grows up from the centre line, the bottom half grows down from
|
# grows up from the centre line, the bottom half grows down from
|
||||||
# it (same values, flipped rows, dimmed like a reflection).
|
# it (same values, flipped rows, dimmed like a reflection).
|
||||||
top_height = height // 2
|
top_height = height // 2
|
||||||
bottom_height = height - top_height
|
bottom_height = height - top_height
|
||||||
lines = self._bars(frame, width, top_height, phase, dim)
|
lines = self._bars(snapshot, width, top_height, phase, dim)
|
||||||
mirrored = self._bars(
|
mirrored = self._bars(
|
||||||
frame, width, bottom_height, phase + 5, True, peaks=False
|
snapshot, width, bottom_height, phase + 5, True, peaks=False
|
||||||
)
|
)
|
||||||
mirrored.reverse()
|
mirrored.reverse()
|
||||||
lines.extend(mirrored)
|
lines.extend(mirrored)
|
||||||
return Group(*lines)
|
return frame.wrap(
|
||||||
|
lines, outer_width, outer_height, self.border_title
|
||||||
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# history
|
# history
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, cast
|
|||||||
|
|
||||||
from rich.console import Group, RenderableType
|
from rich.console import Group, RenderableType
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from textual.containers import Horizontal
|
from textual.containers import Horizontal, Vertical
|
||||||
from textual.css.query import NoMatches
|
from textual.css.query import NoMatches
|
||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ from .common import (
|
|||||||
time_ago,
|
time_ago,
|
||||||
truncate,
|
truncate,
|
||||||
)
|
)
|
||||||
|
from .frame import FrameBottom, FrameSide, FrameTop
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..app import BeefyPlayerApp
|
from ..app import BeefyPlayerApp
|
||||||
@@ -36,10 +37,18 @@ class StagePanel(Widget):
|
|||||||
self.border_title = "ON AIR"
|
self.border_title = "ON AIR"
|
||||||
|
|
||||||
def compose(self):
|
def compose(self):
|
||||||
with Horizontal(id="stage_top"):
|
# A container cannot paint its own content, so the frame is made of
|
||||||
yield ArtPane()
|
# thin widgets: top, bottom, and a column either side of the body.
|
||||||
yield StageInfo()
|
yield FrameTop("ON AIR", id="stage_frame_top")
|
||||||
yield HistoryPane()
|
with Horizontal(id="stage_middle"):
|
||||||
|
yield FrameSide("left", id="stage_frame_left")
|
||||||
|
with Vertical(id="stage_body"):
|
||||||
|
with Horizontal(id="stage_top"):
|
||||||
|
yield ArtPane()
|
||||||
|
yield StageInfo()
|
||||||
|
yield HistoryPane()
|
||||||
|
yield FrameSide("right", id="stage_frame_right")
|
||||||
|
yield FrameBottom(id="stage_frame_bottom")
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self.refresh_art()
|
self.refresh_art()
|
||||||
@@ -150,7 +159,9 @@ class StageInfo(Widget):
|
|||||||
if song.art_url and album.plain.strip():
|
if song.art_url and album.plain.strip():
|
||||||
album.append(" ▣", style="cyan")
|
album.append(" ▣", style="cyan")
|
||||||
else:
|
else:
|
||||||
artist = Text("waiting for track data…", style="italic yellow")
|
# No track metadata from this catalogue: keep the line, leave it
|
||||||
|
# blank rather than filling it with an apology.
|
||||||
|
artist = Text("")
|
||||||
fallback = station.last_playing or "tuning in"
|
fallback = station.last_playing or "tuning in"
|
||||||
title = Text(marquee(fallback, width), style="bold")
|
title = Text(marquee(fallback, width), style="bold")
|
||||||
album = Text(truncate(station.genre_label, width), style="dim")
|
album = Text(truncate(station.genre_label, width), style="dim")
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
"""Station list with favourites, live markers and a filter."""
|
"""Station list with favourites, live markers, a filter and a scrollbar."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
from rich.cells import cell_len
|
from rich.cells import cell_len
|
||||||
from rich.console import Group, RenderableType
|
from rich.console import RenderableType
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
|
|
||||||
|
from . import frame
|
||||||
from .common import compact_count, truncate
|
from .common import compact_count, truncate
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..app import BeefyPlayerApp
|
from ..app import BeefyPlayerApp
|
||||||
|
|
||||||
|
#: Columns the scrollbar occupies (a gap plus the bar itself).
|
||||||
|
GUTTER = 2
|
||||||
|
|
||||||
|
|
||||||
class StationList(Widget):
|
class StationList(Widget):
|
||||||
can_focus = False
|
can_focus = False
|
||||||
@@ -22,34 +26,89 @@ class StationList(Widget):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self.border_title = "STATIONS"
|
self.border_title = "STATIONS"
|
||||||
|
|
||||||
|
# -- title: filter + position --------------------------------------- #
|
||||||
|
|
||||||
def set_filter(self, query: str) -> None:
|
def set_filter(self, query: str) -> None:
|
||||||
|
self._sync_title(query)
|
||||||
|
|
||||||
|
def _sync_title(self, query: str | None = None) -> None:
|
||||||
|
"""``STATIONS · /jazz · 3/12`` — the filter and where you are in it."""
|
||||||
|
if query is None:
|
||||||
|
query = ""
|
||||||
|
total = 0
|
||||||
|
index = 0
|
||||||
|
if self.is_mounted:
|
||||||
|
app = cast("BeefyPlayerApp", self.app)
|
||||||
|
query = app.query
|
||||||
|
total = len(app.view)
|
||||||
|
index = app.index
|
||||||
|
|
||||||
safe_query = query.replace("[", "(").replace("]", ")") if query else ""
|
safe_query = query.replace("[", "(").replace("]", ")") if query else ""
|
||||||
title = f"STATIONS · /{safe_query}" if safe_query else "STATIONS"
|
parts = ["STATIONS"]
|
||||||
|
if safe_query:
|
||||||
|
parts.append(f"/{safe_query}")
|
||||||
|
if total > 1:
|
||||||
|
parts.append(f"{min(index + 1, total)}/{total}")
|
||||||
|
|
||||||
|
title = " · ".join(parts)
|
||||||
if self.border_title != title:
|
if self.border_title != title:
|
||||||
self.border_title = title
|
self.border_title = title
|
||||||
|
|
||||||
|
def refresh(self, *args, **kwargs): # type: ignore[override]
|
||||||
|
# Every cursor move ends in a refresh, so this is the one place that
|
||||||
|
# has to keep the title's position readout honest.
|
||||||
|
if self.is_mounted:
|
||||||
|
self._sync_title()
|
||||||
|
return super().refresh(*args, **kwargs)
|
||||||
|
|
||||||
def on_mouse_scroll_down(self, event) -> None:
|
def on_mouse_scroll_down(self, event) -> None:
|
||||||
cast("BeefyPlayerApp", self.app).action_cursor_down()
|
cast("BeefyPlayerApp", self.app).action_cursor_down()
|
||||||
|
|
||||||
def on_mouse_scroll_up(self, event) -> None:
|
def on_mouse_scroll_up(self, event) -> None:
|
||||||
cast("BeefyPlayerApp", self.app).action_cursor_up()
|
cast("BeefyPlayerApp", self.app).action_cursor_up()
|
||||||
|
|
||||||
|
# -- scrollbar geometry --------------------------------------------- #
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def thumb(height: int, total: int, first: int) -> set[int]:
|
||||||
|
"""Rows of the pane the scrollbar thumb covers."""
|
||||||
|
if total <= height:
|
||||||
|
return set(range(height))
|
||||||
|
size = max(1, round(height * height / total))
|
||||||
|
span = max(0, total - height)
|
||||||
|
top = 0 if span == 0 else round(first * (height - size) / span)
|
||||||
|
return set(range(top, min(height, top + size)))
|
||||||
|
|
||||||
def render(self) -> RenderableType:
|
def render(self) -> RenderableType:
|
||||||
app = cast("BeefyPlayerApp", self.app)
|
app = cast("BeefyPlayerApp", self.app)
|
||||||
width = max(12, self.size.width)
|
width = max(12, self.size.width)
|
||||||
height = max(1, self.size.height)
|
height = max(3, self.size.height)
|
||||||
|
inner_width, inner_height = frame.inner_size(width, height)
|
||||||
view = app.view
|
view = app.view
|
||||||
|
|
||||||
if not view:
|
if not view:
|
||||||
message = "no stations match" if app.query else "loading stations…"
|
message = "no stations match" if app.query else "loading stations…"
|
||||||
return Group(Text(truncate(message, width), style="dim italic"))
|
return frame.wrap(
|
||||||
|
[Text(truncate(message, inner_width), style="dim italic")],
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
self.border_title,
|
||||||
|
)
|
||||||
|
|
||||||
|
total = len(view)
|
||||||
first = 0
|
first = 0
|
||||||
if len(view) > height:
|
if total > inner_height:
|
||||||
first = max(0, min(app.index - height // 2, len(view) - height))
|
first = max(0, min(app.index - inner_height // 2, total - inner_height))
|
||||||
|
|
||||||
|
scrollable = total > inner_height
|
||||||
|
gutter = GUTTER if scrollable else 0
|
||||||
|
content = max(4, inner_width - gutter)
|
||||||
|
thumb = (
|
||||||
|
self.thumb(inner_height, total, first) if scrollable else set()
|
||||||
|
)
|
||||||
|
|
||||||
lines: list[Text] = []
|
lines: list[Text] = []
|
||||||
for offset in range(min(height, len(view))):
|
for offset in range(min(inner_height, total)):
|
||||||
index = first + offset
|
index = first + offset
|
||||||
station = view[index]
|
station = view[index]
|
||||||
selected = index == app.index
|
selected = index == app.index
|
||||||
@@ -64,32 +123,29 @@ class StationList(Widget):
|
|||||||
|
|
||||||
count_text = ""
|
count_text = ""
|
||||||
count_width = 0
|
count_width = 0
|
||||||
if width >= 26 and station.listeners > 0:
|
if content >= 26 and station.listeners > 0:
|
||||||
count_text = f"{compact_count(station.listeners)} ♪"
|
count_text = f"{compact_count(station.listeners)} ♪"
|
||||||
count_width = cell_len(count_text) + 1
|
count_width = cell_len(count_text) + 1
|
||||||
|
|
||||||
prefix = marker + star + play + " "
|
prefix = marker + star + play + " "
|
||||||
available = width - cell_len(prefix) - count_width
|
available = content - cell_len(prefix) - count_width
|
||||||
title = truncate(station.title, max(4, available))
|
title = truncate(station.title, max(4, available))
|
||||||
gap = max(0, width - cell_len(prefix) - cell_len(title) - count_width)
|
gap = max(0, content - cell_len(prefix) - cell_len(title) - count_width)
|
||||||
|
|
||||||
if selected:
|
if selected:
|
||||||
base = ""
|
|
||||||
marker_style = "magenta"
|
marker_style = "magenta"
|
||||||
title_style = "bold underline"
|
title_style = "bold underline"
|
||||||
count_style = "bold"
|
count_style = "bold"
|
||||||
elif current:
|
elif current:
|
||||||
base = ""
|
|
||||||
marker_style = "cyan"
|
marker_style = "cyan"
|
||||||
title_style = "cyan"
|
title_style = "cyan"
|
||||||
count_style = "dim"
|
count_style = "dim"
|
||||||
else:
|
else:
|
||||||
base = ""
|
|
||||||
marker_style = ""
|
marker_style = ""
|
||||||
title_style = ""
|
title_style = ""
|
||||||
count_style = "dim"
|
count_style = "dim"
|
||||||
|
|
||||||
row = Text(no_wrap=True, style=base)
|
row = Text(no_wrap=True)
|
||||||
row.append(marker, style=marker_style)
|
row.append(marker, style=marker_style)
|
||||||
row.append(star, style="bold yellow" if station.favorite else "")
|
row.append(star, style="bold yellow" if station.favorite else "")
|
||||||
row.append(play, style="cyan" if playing else "")
|
row.append(play, style="cyan" if playing else "")
|
||||||
@@ -99,8 +155,16 @@ class StationList(Widget):
|
|||||||
if count_text:
|
if count_text:
|
||||||
row.append(count_text, style=count_style)
|
row.append(count_text, style=count_style)
|
||||||
used = cell_len(prefix) + cell_len(title) + gap + cell_len(count_text)
|
used = cell_len(prefix) + cell_len(title) + gap + cell_len(count_text)
|
||||||
if used < width:
|
if used < content:
|
||||||
row.append(" " * (width - used))
|
row.append(" " * (content - used))
|
||||||
|
|
||||||
|
if scrollable:
|
||||||
|
# Just the thumb, in the frame's own grey: no rail.
|
||||||
|
row.append(" ")
|
||||||
|
row.append(
|
||||||
|
"▐" if offset in thumb else " ",
|
||||||
|
style=frame.EDGE,
|
||||||
|
)
|
||||||
lines.append(row)
|
lines.append(row)
|
||||||
|
|
||||||
return Group(*lines)
|
return frame.wrap(lines, width, height, self.border_title)
|
||||||
+2
-2
@@ -1,13 +1,13 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "beefy-player"
|
name = "beefy-player"
|
||||||
version = "0.1.0"
|
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"
|
readme = "README.md"
|
||||||
license = "WTFPL"
|
license = "WTFPL"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
authors = [{ name = "beefy-player contributors" }]
|
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 = [
|
classifiers = [
|
||||||
"Environment :: Console",
|
"Environment :: Console",
|
||||||
"Intended Audience :: End Users/Desktop",
|
"Intended Audience :: End Users/Desktop",
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ Version=2
|
|||||||
assert base.is_playlist("https://x/y.pls?foo=1") is True
|
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://ice1.somafm.com/x-128-mp3") is True
|
||||||
assert base.is_direct_stream("https://x/y.m3u") is False
|
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:
|
def test_parse_songs_and_dates() -> None:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""The BBS frames: grey edges, corners fading white → grey → cyan."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
from beefy_player.widgets import frame
|
||||||
|
|
||||||
|
|
||||||
|
def styles_of(line: Text) -> list[tuple[str, str]]:
|
||||||
|
return [
|
||||||
|
(line.plain[span.start : span.end], str(span.style)) for span in line.spans
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_the_top_right_corner_fades_on_the_top_edge() -> None:
|
||||||
|
spans = styles_of(frame.top(30))
|
||||||
|
assert spans[0] == ("╭", "bright_black") # plain grey corner
|
||||||
|
# the fade runs into the top-right corner: cyan, bright cyan, light grey
|
||||||
|
assert [style for _text, style in spans[-5:-1]] == [
|
||||||
|
"bright_black",
|
||||||
|
"cyan",
|
||||||
|
"bright_cyan",
|
||||||
|
"white",
|
||||||
|
]
|
||||||
|
assert spans[-1] == ("╮", "bold bright_white")
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_the_bottom_left_corner_fades_on_the_bottom_edge() -> None:
|
||||||
|
spans = styles_of(frame.bottom(30))
|
||||||
|
assert spans[0] == ("╰", "bold bright_white")
|
||||||
|
assert [style for _text, style in spans[1:4]] == ["white", "bright_cyan", "cyan"]
|
||||||
|
assert spans[-1] == ("╯", "bright_black") # plain grey corner
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_sides_glow_towards_their_own_corner_only() -> None:
|
||||||
|
# left column fades up from the bottom-left corner
|
||||||
|
assert [frame.side_style(row, 10, "left") for row in range(10)] == [
|
||||||
|
"bright_black",
|
||||||
|
"bright_black",
|
||||||
|
"bright_black",
|
||||||
|
"bright_black",
|
||||||
|
"bright_black",
|
||||||
|
"bright_black",
|
||||||
|
"bright_black",
|
||||||
|
"cyan",
|
||||||
|
"bright_cyan",
|
||||||
|
"white",
|
||||||
|
]
|
||||||
|
# right column fades down from the top-right corner
|
||||||
|
assert [frame.side_style(row, 10, "right") for row in range(10)] == [
|
||||||
|
"white",
|
||||||
|
"bright_cyan",
|
||||||
|
"cyan",
|
||||||
|
"bright_black",
|
||||||
|
"bright_black",
|
||||||
|
"bright_black",
|
||||||
|
"bright_black",
|
||||||
|
"bright_black",
|
||||||
|
"bright_black",
|
||||||
|
"bright_black",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_title_sits_after_the_left_fade() -> None:
|
||||||
|
line = frame.top(30, "STATIONS")
|
||||||
|
assert line.plain.startswith("╭─ STATIONS ─")
|
||||||
|
assert "STATIONS" in line.plain
|
||||||
|
# a pane too narrow for the title drops it rather than truncating
|
||||||
|
assert "STATIONS" not in frame.top(10, "STATIONS").plain
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrap_frames_the_content_on_every_row() -> None:
|
||||||
|
content = [Text("hello"), Text("world")]
|
||||||
|
group = frame.wrap(content, 20, 6, "PANE")
|
||||||
|
lines = [line.plain for line in group.renderables]
|
||||||
|
assert len(lines) == 6
|
||||||
|
assert lines[0].startswith("╭") and lines[-1].startswith("╰")
|
||||||
|
assert all(len(line) == 20 for line in lines)
|
||||||
|
assert all(line.startswith("│") and line.endswith("│") for line in lines[1:-1])
|
||||||
|
assert lines[1].startswith("│hello")
|
||||||
@@ -86,6 +86,22 @@ def test_the_font_still_spells_soma_the_way_the_original_logo_did() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("slug", sorted(PROVIDERS))
|
||||||
|
def test_the_caption_block_starts_on_one_column(slug: str) -> None:
|
||||||
|
"""Art rows are right-trimmed; a row ending in a blank cell must not pull
|
||||||
|
its caption a column to the left."""
|
||||||
|
provider = PROVIDERS[slug]
|
||||||
|
captions = provider.hero_lines
|
||||||
|
offset = max(0, (len(provider.hero) - len(captions)) // 2)
|
||||||
|
lines = render_header(200, height=len(provider.hero), provider=provider)
|
||||||
|
lines = lines[offset : offset + len(captions)]
|
||||||
|
columns = {
|
||||||
|
line.index(caption)
|
||||||
|
for line, caption in zip(lines, captions, strict=True)
|
||||||
|
}
|
||||||
|
assert len(columns) == 1, (slug, columns)
|
||||||
|
|
||||||
|
|
||||||
def test_every_catalogue_has_a_three_row_wordmark() -> None:
|
def test_every_catalogue_has_a_three_row_wordmark() -> None:
|
||||||
for slug, provider in PROVIDERS.items():
|
for slug, provider in PROVIDERS.items():
|
||||||
assert len(provider.hero) == 3, slug
|
assert len(provider.hero) == 3, slug
|
||||||
|
|||||||
+23
-2
@@ -22,7 +22,22 @@ def rendered_text(app) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def test_registry_lists_somafm_as_the_default() -> None:
|
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 isinstance(get_provider(), SomaFMProvider)
|
||||||
assert get_provider("somafm") is get_provider()
|
assert get_provider("somafm") is get_provider()
|
||||||
assert isinstance(get_provider("tilderadio"), TilderadioProvider)
|
assert isinstance(get_provider("tilderadio"), TilderadioProvider)
|
||||||
@@ -177,7 +192,13 @@ async def test_p_opens_the_picker_and_switches_catalogue() -> None:
|
|||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
assert isinstance(app.screen, ProviderScreen)
|
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()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
|
|||||||
@@ -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") == []
|
||||||
@@ -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") == []
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""The station pane: paging, the position readout and the scrollbar."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from beefy_player.app import BeefyPlayerApp
|
||||||
|
from beefy_player.widgets.station_list import StationList
|
||||||
|
|
||||||
|
|
||||||
|
def app_for(provider: str, *, offline: bool = True) -> BeefyPlayerApp:
|
||||||
|
return BeefyPlayerApp(provider=provider, autoplay=False, offline=offline, audio=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_thumb_geometry() -> None:
|
||||||
|
# nothing to scroll: the whole track is the thumb
|
||||||
|
assert StationList.thumb(10, 10, 0) == set(range(10))
|
||||||
|
# top of a long list
|
||||||
|
top = StationList.thumb(10, 100, 0)
|
||||||
|
assert 0 in top and len(top) >= 1
|
||||||
|
# bottom of the same list ends at the last row
|
||||||
|
bottom = StationList.thumb(10, 100, 90)
|
||||||
|
assert 9 in bottom
|
||||||
|
# mid-list sits between the two
|
||||||
|
middle = StationList.thumb(10, 100, 45)
|
||||||
|
assert min(middle) > min(top) and max(middle) < max(bottom)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_paging_moves_a_screen_and_back() -> None:
|
||||||
|
app = app_for("somafm")
|
||||||
|
async with app.run_test(size=(100, 26)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
pane = app.query_one(StationList)
|
||||||
|
page = pane.size.height - 1
|
||||||
|
start = app.index
|
||||||
|
|
||||||
|
await pilot.press("pagedown")
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.index == min(start + page, len(app.view) - 1)
|
||||||
|
|
||||||
|
await pilot.press("pageup")
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.index == start
|
||||||
|
|
||||||
|
# the bottom of the list clamps instead of wrapping
|
||||||
|
await pilot.press("shift+g", "pagedown")
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.index == len(app.view) - 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_title_reads_out_the_position() -> None:
|
||||||
|
app = app_for("somafm")
|
||||||
|
async with app.run_test(size=(100, 26)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
pane = app.query_one(StationList)
|
||||||
|
assert re.fullmatch(r"STATIONS · \d+/\d+", pane.border_title)
|
||||||
|
await pilot.press("down")
|
||||||
|
await pilot.pause()
|
||||||
|
first, total = pane.border_title.split(" · ")[1].split("/")
|
||||||
|
assert int(total) == len(app.view)
|
||||||
|
assert int(first) == app.index + 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_scrollbar_appears_only_when_the_list_overflows() -> None:
|
||||||
|
app = app_for("somafm")
|
||||||
|
async with app.run_test(size=(100, 26)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
pane = app.query_one(StationList)
|
||||||
|
rows = [line.plain for line in pane.render().renderables]
|
||||||
|
assert rows[0].startswith("╭") and rows[-1].startswith("╰")
|
||||||
|
content = rows[1:-1]
|
||||||
|
# thumb only, in the frame grey: blank cells elsewhere, no rail
|
||||||
|
assert all(row[-2] in "▐ " for row in content)
|
||||||
|
assert any(row[-2] == "▐" for row in content)
|
||||||
|
|
||||||
|
app = app_for("rinse") # three stations, no scrolling
|
||||||
|
async with app.run_test(size=(100, 26)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
pane = app.query_one(StationList)
|
||||||
|
rows = [line.plain for line in pane.render().renderables]
|
||||||
|
assert rows[0].startswith("╭") and rows[-1].startswith("╰")
|
||||||
|
assert sum("Rinse" in row or "Kool" in row or "SWU" in row for row in rows) == 3
|
||||||
|
# three stations fit: no thumb in the content rows
|
||||||
|
assert not any(row[-2] == "▐" for row in rows[1:-1])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_filter_keeps_the_readout_in_step() -> None:
|
||||||
|
app = app_for("somafm")
|
||||||
|
async with app.run_test(size=(100, 26)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("slash")
|
||||||
|
await pilot.pause()
|
||||||
|
app.query_one("SearchInput").value = "drone"
|
||||||
|
await pilot.pause()
|
||||||
|
pane = app.query_one(StationList)
|
||||||
|
assert pane.border_title.startswith("STATIONS · /drone · ")
|
||||||
|
filtered = len(app.view)
|
||||||
|
assert pane.border_title.endswith(f"/{filtered}")
|
||||||
+7
-2
@@ -52,8 +52,13 @@ async def test_backgrounds_use_terminal_default_and_borders_are_ansi() -> None:
|
|||||||
assert widget.styles.background.ansi == -1, widget_type
|
assert widget.styles.background.ansi == -1, widget_type
|
||||||
assert widget.styles.color.ansi == -1, widget_type
|
assert widget.styles.color.ansi == -1, widget_type
|
||||||
|
|
||||||
border = app.query_one(StationList).styles.border.top
|
# panes draw their own frames now: dim edges, gradient corners
|
||||||
assert border[1].ansi is not None # an ANSI palette index, not RGB
|
pane = app.query_one(StationList)
|
||||||
|
assert pane.styles.border.top[0] == "" # no framework border any more
|
||||||
|
framed = pane.render().renderables
|
||||||
|
corners = [str(span.style) for span in framed[0].spans]
|
||||||
|
assert framed[0].plain.startswith("╭")
|
||||||
|
assert any(style.strip() for style in corners)
|
||||||
|
|
||||||
await pilot.press("slash")
|
await pilot.press("slash")
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|||||||
Reference in new issue
Block a user