Compare commits

..
5 Commits
Author SHA1 Message Date
beefcarver e0e1ad5df3 chore(release): bump version to 1.2.1 2026-11-04 15:49:28 -05:00
beefcarver ec2ca72efb fix(frame): start the corner fade at the brightest palette stop
A ladder is a hue walk, not a brightness walk, so painting the hot end
backwards put the dim colour at the corner.  Sort the ramp by stock
brightness and follow the pane height for the bottom-left glow.
2026-11-04 15:49:26 -05:00
beefcarver 81116282cb chore(release): bump version to 1.2.0 2026-11-04 15:11:54 -05:00
beefcarver 03297e8751 docs(readme): rewrite for public release 2026-11-04 15:11:49 -05:00
beefcarver 638cc9eddb feat(radiobrowser): add search, paging and favourite lookup 2026-11-04 15:11:42 -05:00
16 changed files with 1259 additions and 611 deletions

No files matched your search

+127 -530
View File
@@ -3,346 +3,76 @@
![beefy-player demo](assets/beefy-player-demo.gif) ![beefy-player demo](assets/beefy-player-demo.gif)
A flashy terminal player for **internet radio** — a live spectrum analyser, A flashy terminal player for **internet radio** — a live spectrum analyser,
song history, and full desktop media integration, drawn with your terminal's song history and album art, with desktop media integration on Linux and macOS.
own ANSI palette on the terminal's default background so it fits any theme It draws with your terminal's own ANSI palette on the terminal's default
(including transparent terminals). background, so it fits any theme, including transparent terminals.
The station catalogue is pluggable. Beefy Player ships with thirteen catalogues Thirteen station catalogues ship today, from SomaFM's curated channels to the
— **[SomaFM](https://somafm.com)** (≈50 curated channels), 62,000-station Radio Browser directory, loaded as you scroll and searched live
**[Radio Paradise](https://radioparadise.com)** (five mixes), the community as you type. Press `p` to switch catalogue without restarting.
stations **[tilderadio](https://tilderadio.org)**,
**[Bassdrive](https://bassdrive.com)**, **[dublab](https://dublab.com)**,
**[Rinse FM](https://rinse.fm)**, **[Refuge Worldwide](https://refugeworldwide.com)**,
**[LYL Radio](https://lyl.live)**, **[The Lot Radio](https://thelotradio.com)**,
**[Cashmere Radio](https://cashmereradio.com)**,
**[Datafruits](https://datafruits.fm)** and **[aNONradio](https://anonradio.net)**,
plus the **[Radio Browser](https://www.radio-browser.info)** directory
(300 stations) — and the UI never talks to a station catalogue directly: it asks a
[provider](#providers) for stations, song history and stream URLs. Press `p` to
switch catalogue without restarting. Adding another internet-radio catalogue is
a self-contained module, not a rewrite.
It runs the same way on **Linux** (PulseAudio/PipeWire) and **macOS**
(CoreAudio, iTerm2/Kitty/WezTerm): MPRIS on the desktop bus where that exists,
macOS Now Playing (Control Center and the media keys) where it does not.
No existing radio client is used: the station list, playlists, song history,
audio control, spectrum visualiser and media-session integration are all
implemented here from public endpoints and open protocols.
## Features ## Features
- **Live spectrum analyser** — 72 log-spaced bands with peak caps, a moving - **Live spectrum analyser** — 72 log-spaced bands with peak caps, a palette
gradient taken from your terminal's ANSI palette and a symmetric mirrored gradient and a mirrored spectrum. Six modes (`v`): mirrored bars, plain bars,
spectrum. Six modes (`v`): mirrored bars, plain bars, oscilloscope, oscilloscope, scrolling waterfall, radial spectrum and a high-resolution
scrolling waterfall spectrogram, a radial spectrum wrapped around the braille oscilloscope.
centre, and a high-resolution braille oscilloscope (2×4 dots per cell). - **Synced to what you hear** — the analyser listens to your actual audio
- **Synced spectrum, not a guess** — by default the analyser records your output by default, so the bars follow the music. If that is unavailable it
actual audio output, so the bars follow what you hear (`SYNC`): the falls back to decoding the stream, then to a simulated display.
PulseAudio/PipeWire monitor via `parec` on Linux, and a native Core Audio - **Now playing and history** — artist, title, album, DJ, listeners, bitrate
process tap on macOS (no driver, no BlackHole, nothing to install). Without and "played N minutes ago", plus the full recent history. A clickable
an output capture it falls back to a muted second `ffmpeg` decode of the **Bandcamp ↗** link appears when the current track can be found there.
stream (`FFT`, can drift), then to a synthesized display (`SIM`). - **Real album art** — native images in Kitty, iTerm2 and WezTerm; an
- **Real artwork on iTerm2, Kitty and WezTerm** — Kitty uses the kitty ANSI-shaded mosaic in every other terminal.
graphics protocol; iTerm2 (including on macOS) and WezTerm use their native - **Thirteen station catalogues** — SomaFM (≈50 channels), Radio Paradise,
iTerm2 inline-image protocol. Images occupy exactly the ON AIR art cell box tilderadio, Bassdrive, dublab, Rinse FM, Refuge Worldwide, LYL Radio, The Lot
and fall back to the procedural ANSI mosaic for other terminals, failed Radio, Cashmere Radio, Datafruits, aNONradio and the Radio Browser directory
loads, or blocked graphics passthrough. Images are cached in (loaded as you scroll in popularity order, with live search across all
`~/.cache/beefy-player/artwork`, letterboxed rather than cropped, and an 62,000).
image that carries an alpha channel is composited onto black — a transparent - **Favourites, search and schedules** — favourites float to the top and are
PNG drawn over the mosaic reads as noise behind a station logo. remembered between runs, including directory stations outside the loaded
- **Now playing** — artist, title, album, DJ, listeners, format/bitrate and page; search by title, genre or description, with live server-side search
"played N minutes ago", with procedural ANSI-shaded album art that changes across the Radio Browser directory; single-stream stations show their
per track. When the current track can be found on Bandcamp, a clickable broadcast schedule instead of a one-item list.
**Bandcamp ↗** link appears below it; otherwise the line stays hidden. - **Desktop media integration** — MPRIS on Linux (GNOME/KDE widgets, lock
Catalogues can opt out with `bandcamp = False` (tilderadio does), and a screens, `playerctl` and the media keys) and Now Playing on macOS (Control
provider can fill the `Station.presenter` line instead — tilderadio shows Center and the media keys).
`auto dj · library rotation` between live shows and `dj <name>` during one. - **Sixteen palettes, theme-aware** — every colour is native ANSI against the
- **Last played** — the full recent history, newest first. terminal's default background, so transparent terminals stay transparent and
- **Every station** — with SomaFM that is the whole `channels.json` roster the TUI follows your colour scheme. Press `c` to cycle palettes.
(≈50 channels), live listener counts and genre search; tilderadio is a - **Graceful offline** — cached stations and schedules, a bundled fallback
single-station catalogue, and the list simply holds one. Long lists get a grey list, automatic mirror failover and `ffplay` when `mpv` is missing.
thumb down the right edge (no rail) and a `12/51` position in the pane title;
`pgup`/`pgdn` move a screen at a time.
- **Favourites** that float to the top, remembered between runs.
- **Pluggable catalogues** — one `providers` module per radio network, with a
`p` picker in the UI, `--provider` on the command line, and the choice
remembered per catalogue (including the last station you tuned). Switching to
a single-station catalogue tunes it straight away; a multi-station one waits
for `enter`.
- **A hero per catalogue** — the top-left art is the catalogue's own name in a
shared half-block font: SomaFM in the spectrum gradient, tilderadio in the
terminal's default colour, Bassdrive in its two blues. Every hero is three
rows tall, so the header keeps one height.
- **Schedule pane** — a single-stream catalogue gets a broadcast schedule
instead of a list of one station: who is on air now, since/until when, and
the upcoming shows grouped by day.
- **Desktop media integration** — on Linux an
`org.mpris.MediaPlayer2.beefyplayer.instance<pid>` player exposes
`PlaybackStatus`, `Metadata` (title/artist/album/artUrl), `Volume`, and
`Play`/`Pause`/`PlayPause`/`Stop`/`Next`/`Previous`, so GNOME/KDE widgets,
lock screens, `playerctl` and the media keys pick it up. On macOS the audio
engine (mpv) publishes to the system **Now Playing** session, so Control
Center, the menu bar and the keyboard media keys drive the app the same way.
- **Sixteen palettes** — every colour comes from one ladder of ANSI names per
theme: the spectrum analyser, the album-art mosaic, the provider wordmarks
and the pane corners all draw from it. `c` cycles spectrum, rainbow, ocean,
ember, mint, sunset, violet, acid, neon, sand, blood, forest, ice, steel,
candy and mono; the choice is remembered.
- **Theme-aware** — every accent is emitted as native ANSI (not RGB), and the
background is the terminal default (`SGR 49`), so transparent terminals stay
transparent and the TUI adopts your shell/terminal colour scheme.
- **Graceful degradation** — works with cached data offline, falls back to a
bundled station list, reconnects across ICE mirrors automatically, and uses
`ffplay` if `mpv` is missing.
## Requirements ## Installation
`beefy-player` is a normal Python application: there is nothing to compile and ## Usage
no distro-specific packaging. It runs on any Linux distribution or macOS with
Python **3.10+** and a few command-line audio tools on `$PATH`.
| tool | why |
| ---- | --- |
| `python3` | runtime (3.10 or newer; no exact version pin) |
| [`uv`](https://docs.astral.sh/uv/) | creates the venv and installs every Python dependency from `uv.lock` |
| `mpv` | playback and JSON IPC control |
| `ffmpeg` | muted stream decode for the fallback analyser |
| `parec` / `pactl` *(Linux)* | synced monitor capture for the spectrum (`pulseaudio-utils`; works with PipeWire) |
| D-Bus session bus *(Linux)* | MPRIS desktop media integration (`playerctl` is handy for testing) |
On macOS the spectrum capture is part of the app: it uses the Core Audio
process-tap API (macOS 14.2 or newer), so there is no loopback driver to
install.
On macOS the media integration needs nothing extra: mpv is what the system's
Now Playing session (Control Center, media keys) talks to.
Install `uv` once (skip if it is already there):
```bash ```bash
curl -LsSf https://astral.sh/uv/install.sh | sh beefy-player
``` ```
`brew install uv`, `pipx install uv`, or a distro package also work. If your
system Python is older than 3.10, let uv manage one: `uv python install 3.12`.
### Linux
Install these with your package manager. Names are usually:
```bash
# Debian / Ubuntu
sudo apt install python3 mpv ffmpeg pulseaudio-utils dbus playerctl
# Fedora (enable RPM Fusion for ffmpeg if needed)
sudo dnf install python3 mpv ffmpeg pulseaudio-utils dbus-tools playerctl
# Arch / Manjaro
sudo pacman -S python mpv ffmpeg libpulse dbus playerctl
# openSUSE
sudo zypper install python3 mpv ffmpeg pulseaudio-utils dbus-1-tools playerctl
```
For other distributions, install the equivalents. On a headless machine you
can skip the audio/D-Bus tools and use `--no-audio` for a UI-only demo, though
the synced spectrum needs `parec`/`pactl`.
### macOS
```bash
brew install mpv ffmpeg uv # ffplay ships with ffmpeg
```
That is enough to run: playback, artwork in iTerm2/Kitty/WezTerm, the synced
Core Audio spectrum tap and the macOS Now Playing integration all work out of
the box.
The first time the analyser starts it asks macOS for **System Audio
Recording** permission — a one-off prompt for your terminal (iTerm2, Kitty,
WezTerm, …). Approve it under *System Settings → Privacy & Security → System
Audio Recording*; no loopback driver, Multi-Output Device or audio routing is
needed. If the permission is denied the tap only ever hands out silence, the
analyser notices and falls back to `FFT` instead of showing dead bars.
The tap records the **default output device**, which is also what mpv uses; if
you point mpv elsewhere with `BEEFY_MPV_ARGS='--audio-device=...'` the
analyser falls back to `FFT`.
Terminal image rendering uses [Pillow](https://python-pillow.org/) and is
implemented directly (Kitty TGP + iTerm2 OSC 1337) — no terminal probing.
## Providers
A *provider* is one station catalogue. It knows how to fetch the station list,
how to fetch a station's recently played songs, and how to turn a station into
a stream URL. Everything else — the UI, the player, the spectrum, the media
session — is provider-neutral.
```python
# beefy_player/providers/base.py
class RadioProvider(ABC):
id, name, homepage, brand, tagline, station_source, history_source, user_agent
hero, hero_gradient, hero_lines # the header art and its caption
has_schedule # schedule pane instead of a list
bandcamp # look tracks up on Bandcamp?
async def fetch_stations(self, client) -> list[Station]: ...
def load_cached_stations(self) -> list[Station]: ...
def fallback_stations(self) -> list[Station]: ...
async def fetch_history(self, client, station_id) -> list[Song]: ...
def load_cached_history(self, station_id) -> list[Song]: ...
async def fetch_schedule(self, client) -> list[Broadcast]: ...
def load_cached_schedule(self) -> list[Broadcast]: ...
def fallback_urls(self, station) -> list[str]: ...
async def stream_candidates(self, client, station) -> list[str]: ...
```
`hero` rows are rich markup (half blocks); with `hero_gradient` the header
paints plain glyphs with the app's spectrum palette instead, which is how the
SomaFM wordmark stays theme-aware. `has_schedule` swaps the left pane for the
schedule view — the pane asks the provider for `Broadcast` objects (name, DJ,
start, end, live) and never learns where they came from.
SomaFM is the reference implementation, and it is quarantined in its own
package: the `channels.json` schema, the `songs/<station>.json` history, the PLS
layout and the guessed icecast mirrors all live in
`beefy_player/providers/somafm/`. Nothing outside that package imports it; the
app only ever holds a `RadioProvider`.
Thirteen catalogues ship today:
| slug | catalogue | stations | what is on | relays |
| --- | --- | --- | --- | --- |
| `somafm` | [SomaFM](https://somafm.com) | `channels.json` (≈50) | `songs/<station>.json` | `.pls` → icecast mirrors |
| `radioparadise` | [Radio Paradise](https://radioparadise.com) | five mixes | `api/now_playing` (artist, album, cover) | FLAC / AAC / MP3 icecast |
| `tilderadio` | [tilderadio](https://tilderadio.org) | 1 + schedule | `/api/now/` | AzuraCast mounts |
| `bassdrive` | [Bassdrive](https://bassdrive.com) | 1 + schedule | icecast `display-title` (the show) | three mounts + mirrors |
| `dublab` | [dublab](https://dublab.com) | 1 + schedule | dublab's calendar | Airtime mp3 |
| `rinse` | [Rinse FM](https://rinse.fm) | 3 | — | proxy mounts (aac+) |
| `refuge` | [Refuge Worldwide](https://refugeworldwide.com) | 1 | `public.radio.co` status | radio.co mp3 |
| `lyl` | [LYL Radio](https://lyl.live) | 1 | — | icecast mp3 |
| `lot` | [The Lot Radio](https://thelotradio.com) | 1 | — | HLS |
| `cashmere` | [Cashmere Radio](https://cashmereradio.com) | 1 | — | Airtime ogg/mp3 |
| `datafruits` | [Datafruits](https://datafruits.fm) | 1 | streampusher icecast | relay mp3 |
| `anonradio` | [aNONradio](https://anonradio.net) | 1 | icecast title | icecast mp3 |
| `radiobrowser` | [Radio Browser](https://www.radio-browser.info) | 300 (directory) | — | whatever each station publishes |
They are deliberately different shapes: fifty channels with PLS playlists, a
single AzuraCast stream that carries its own history, and an Icecast relay whose
"now playing" is the *show* rather than the track. That is the seam working.
The single-stream catalogues also use the optional capabilities: where a
schedule exists (`tilderadio`, `bassdrive`, `dublab`) the left pane is that
schedule rather than a list containing one station, and none of them do
Bandcamp lookups. Bassdrive is the extreme case — Icecast reports one title and
keeps no history, so the provider maps the title to `Station.dj`/`presenter`
and keeps a rolling record of the shows it has heard.
The community stations share one base class,
`providers/community/base.py`, which knows how to read an Icecast
`status-json.xsl`, a `public.radio.co` status, a scheduled show, or nothing at
all; each station is then ten lines of data (relays, wordmark, tagline). Where
a relay publishes no metadata (Rinse, LYL, The Lot, Cashmere) the app simply
plays it and says so, rather than inventing a track.
`radiobrowser` is the directory case: three hundred of the most-clicked working
stations from the community database, keyless and public-domain, with no
per-station history at all — breadth rather than depth, and the one catalogue
where ON AIR legitimately has nothing to say.
### Header wordmarks
All three heroes come from one font, `beefy_player/providers/wordmark.py`:
four-column capitals six pixels tall, emitted as three rows of half blocks
(`▀`/`▄`/`█`) — the shape of the original SomaFM logo. A provider's `hero.py`
is one line of data:
```python
# beefy_player/providers/bassdrive/hero.py
HERO = render("BASSDRIVE", ["bright_cyan"] * 4 + ["cyan"] * 5)
```
`render()` takes one optional style per character and groups equal neighbours
into markup spans; with no styles the wordmark inherits the terminal's default
foreground (tilderadio). Add a glyph to `GLYPHS` when a catalogue needs a letter
the font does not have yet — `▀█▀` is the I, one pixel thick on purpose.
Image logos are a different problem: `tools/ansi_art.py` converts a PNG into
half-block art (`--key-white` for logos on white, `--preview out.png` to see the
result). No shipped catalogue needs it today, but it is the tool to reach for
when a station's identity is a picture rather than a word.
Adding a catalogue:
1. create `beefy_player/providers/<slug>/` with a `RadioProvider` subclass,
2. register one instance in `beefy_player/providers/__init__.py`,
3. run it with `beefy-player --provider <slug>`, press `p` in the UI, or set
`"provider"` in `~/.config/beefy-player/config.json`.
`RadioProvider.stream_candidates()` already implements the common strategy —
resolve the station's playlists (with an on-disk cache), then try
`fallback_urls()` — so most providers only have to supply the catalogue,
history and mirror URLs.
## Run
From the repository root, sync the environment and start the app:
```bash
uv sync # create .venv and install exactly what uv.lock pins
uv run beefy-player # run inside that environment
```
You do **not** need to activate the venv. `uv run` uses `.venv` automatically,
and re-syncs it first if `pyproject.toml` or `uv.lock` changed. If you prefer a
traditional shell, run `source .venv/bin/activate` and then `beefy-player`.
### Keeping the uv environment in sync
`uv sync` is the command to remember. It is incremental, safe to re-run, and
should be run after pulling changes, switching branches, or editing
`pyproject.toml`:
```bash
uv sync # make .venv match uv.lock
uv sync --frozen # strict: fail if uv.lock is out of date, never rewrite it
uv lock --upgrade # refresh uv.lock to the newest allowed versions
uv sync --upgrade # upgrade dependencies and sync .venv in one step
rm -rf .venv && uv sync # last resort if the venv was built by an older Python
```
Useful flags:
```bash
uv run beefy-player --provider somafm # pick a station catalogue
uv run beefy-player --provider tilderadio # one community stream, tildeverse
uv run beefy-player --play # start the last station immediately
uv run beefy-player --station defcon # pick a station
uv run beefy-player --volume 55
uv run beefy-player --offline # no network; cached/bundled stations
uv run beefy-player --no-mpris # no MPRIS / Now Playing, no media keys
uv run beefy-player --list # print station ids
uv run beefy-player --no-audio # UI demo without mpv/ffmpeg
uv run beefy-player --spectrum-source monitor # force synced output capture
uv run beefy-player --spectrum-source stream # force the muted ffmpeg tap
BEEFY_IMAGES=0 uv run beefy-player # force the ANSI artwork mosaic
BEEFY_IMAGES=tgp uv run beefy-player # force Kitty/TGP artwork
BEEFY_IMAGES=iterm2 uv run beefy-player # force iTerm2/WezTerm artwork
```
Environment variables are prefixed `BEEFY_`; the pre-rename `SOMAFM_` spellings
(`SOMAFM_IMAGES`, `SOMAFM_MPV_ARGS`, `SOMAFM_SPECTRUM_DEVICE`) are still
honoured, so an existing shell profile keeps working.
The UI wants roughly **84×24**; it also works smaller, just less pretty. The UI wants roughly **84×24**; it also works smaller, just less pretty.
## Keys | flag | what it does |
| --- | --- |
| `--provider <slug>` | pick a station catalogue |
| `--station <id>` | pick a station (see `--list`) |
| `--play` | tune the last station right away |
| `--list` | print station ids for the current catalogue |
| `--volume <0-100>` | set the initial volume |
| `--offline` | no network; use cached and bundled stations |
| `--no-audio` | UI demo without `mpv` / `ffmpeg` |
| `--no-mpris` | no MPRIS / Now Playing, no media keys |
| `--spectrum-source monitor\|stream` | force synced output capture or the stream tap |
Environment variables are prefixed `BEEFY_`: `BEEFY_IMAGES` (`tgp`, `iterm2`
or `0`), `BEEFY_MPV_ARGS` and `BEEFY_SPECTRUM_DEVICE`.
### Keys
| key | action | | key | action |
| -------------- | --------------------------------------------- | | --- | --- |
| `↑` / `↓`, `j` / `k` | move through stations | | `↑` / `↓`, `j` / `k` | move through stations |
| `pgup` / `pgdn` | page through stations | | `pgup` / `pgdn` | page through stations |
| `enter` | tune in to the selected station | | `enter` | tune in to the selected station |
@@ -353,20 +83,41 @@ The UI wants roughly **84×24**; it also works smaller, just less pretty.
| `m` | mute | | `m` | mute |
| `f` | favourite (favourites sort to the top) | | `f` | favourite (favourites sort to the top) |
| `/` | filter by title, genre, description | | `/` | filter by title, genre, description |
| `esc` | clear filter / close search | | `esc` | close the search box; press again to clear the filter |
| `v` | visualiser: mirror → bars → wave → waterfall → radial → braille | | `v` | visualiser: mirror → bars → wave → waterfall → radial → braille |
| `c` | colour palette (16 of them, remembered) | | `c` | colour palette (16 of them, remembered) |
| `r` | refresh stations, schedule + song history | | `r` | refresh stations, schedule and song history |
| `p` | switch station catalogue (SomaFM / tilderadio)| | `p` | switch station catalogue |
| `g` / `G` | top / bottom | | `g` / `G` | top / bottom |
| `?` | help | | `?` | help |
| `q` | quit | | `q` | quit |
## Desktop media widgets (MPRIS / Now Playing) ## Station catalogues
**Linux** — while playing, the app exports | catalogue | slug | stations |
`org.mpris.MediaPlayer2.beefyplayer.instance<pid>` on the **session bus**. Check | --- | --- | --- |
it: | [SomaFM](https://somafm.com) | `somafm` | ≈50 curated channels |
| [Radio Paradise](https://radioparadise.com) | `radioparadise` | 5 mixes |
| [tilderadio](https://tilderadio.org) | `tilderadio` | 1 + schedule |
| [Bassdrive](https://bassdrive.com) | `bassdrive` | 1 + schedule |
| [dublab](https://dublab.com) | `dublab` | 1 + schedule |
| [Rinse FM](https://rinse.fm) | `rinse` | 3 |
| [Refuge Worldwide](https://refugeworldwide.com) | `refuge` | 1 |
| [LYL Radio](https://lyl.live) | `lyl` | 1 |
| [The Lot Radio](https://thelotradio.com) | `lot` | 1 |
| [Cashmere Radio](https://cashmereradio.com) | `cashmere` | 1 |
| [Datafruits](https://datafruits.fm) | `datafruits` | 1 |
| [aNONradio](https://anonradio.net) | `anonradio` | 1 |
| [Radio Browser](https://www.radio-browser.info) | `radiobrowser` | 62k · paged + live search |
Pick one with `p` in the UI, `--provider <slug>` on the command line, or by
setting `"provider"` in the config file.
## Desktop media integration
**Linux** — while playing, the app exposes an
`org.mpris.MediaPlayer2.beefyplayer.instance<pid>` player on the session bus,
so GNOME/KDE widgets, lock screens, `playerctl` and the media keys pick it up:
```bash ```bash
playerctl -l playerctl -l
@@ -374,216 +125,62 @@ playerctl metadata --format '{{artist}} — {{title}} ({{album}})'
playerctl play-pause playerctl play-pause
``` ```
`mpris:artUrl` is the track's album art when the provider supplies it, If there is no session bus (SSH without `dbus-run-session`, containers, …)
otherwise the channel logo. If there is no session bus (SSH without MPRIS is skipped and the status bar shows `mpris off`.
`dbus-run-session`, containers, …) MPRIS is skipped and the status bar shows
`mpris off`.
**macOS** — mpv owns the system Now Playing session, so Control Center, the **macOS** — mpv publishes to the system Now Playing session, so Control Center,
menu-bar widget and the keyboard media keys (▶︎/⏸, ⏭, ⏮) all target this app the menu bar and the media keys drive the app while it plays. The status bar
while it is playing. mpv publishes the live stream title (the same shows `nowplaying on`. `--no-mpris` (also spelled `--no-media`) turns the media
“Artist - Title” that the station sends with the audio), and the media keys are keys off.
routed back into the TUI, so `⏭`/`⏮` really change station and the panel, the
spectrum and the station list stay in sync. The status bar shows
`nowplaying on`. There is no D-Bus and no extra dependency involved, and
`--no-mpris` (also spelled `--no-media`) turns the media keys off again.
> macOS shows one Now Playing app at a time. If Music/Safari/another mpv is > macOS shows one Now Playing app at a time. If Music, Safari or another mpv is
> playing, the media keys go there instead — quit it or pause it to hand the > playing, the media keys go there instead.
> keys back.
## How it works ## Configuration
``` Settings live in `~/.config/beefy-player/config.json` (favourites, volume,
beefy_player/ provider and the last station tuned in each catalogue). Cached stations,
app.py Textual app: layout, keys, orchestration schedules and artwork live in `~/.cache/beefy-player/`.
models.py Song + Station, provider-neutral
config.py XDG paths, settings, BEEFY_* env helpers
providers/
base.py RadioProvider interface + HTTP/playlist toolkit
wordmark.py the half-block font every hero is drawn with
somafm/ the SomaFM catalogue (API, hero, bundled fallback list)
radiobrowser/ the community directory (300 stations, no metadata)
radioparadise/ five curated mixes with real track metadata
tilderadio/ the tilderadio catalogue (one station, hero, schedule)
bassdrive/ Bassdrive (Icecast status, show schedule, rolling history)
community/ one module per community station, on a shared base
player.py mpv over a Unix-socket JSON IPC channel
spectrum.py analyser (parec / Core Audio tap / ffmpeg / sim)
macos_tap.py macOS output capture (ctypes + Core Audio)
mediasession.py platform-neutral desktop media face
mpris.py dbus-next MPRIS service
artwork.py Kitty TGP / iTerm2 images with an ANSI mosaic fallback
bandcamp.py "is this track on Bandcamp?" lookups
widgets/ the Textual widgets (schedule pane, catalogue picker, …)
tools/ These stay in the usual XDG locations on every platform, macOS included;
ansi_art.py logo PNG -> half-block ANSI hero (regenerates tilderadio's) `XDG_CONFIG_HOME` and `XDG_CACHE_HOME` are honoured when set.
```
- `beefy_player/providers/base.py` — the provider interface and the shared ## Troubleshooting
toolkit: `httpx` client factory plus `.pls`/`.m3u` parsing and resolution
(cached for 8 hours), so providers only describe their own catalogue.
- `beefy_player/providers/somafm/api.py` — async `httpx` client for:
- `channels.json` → stations, genres, listeners, `.pls` playlist URLs
- `<station>.pls` → resolved direct icecast URLs (cached 8h)
- `songs/<station>.json` → last played tracks (cached on disk)
Playlist and channel caching means the UI opens instantly and still works
offline.
- `beefy_player/providers/tilderadio/api.py` — the same idea for a
single-station catalogue: `/api/now/` carries the station, the live DJ, the
listener count and both `now_playing` and `history[]`, while AzuraCast's
mount list contributes the bitrates (320k mp3, 192k mp3/ogg). Station and
history caches are per catalogue, so the two providers never overwrite each
other's files.
- `beefy_player/app.py` — holds one provider and one `Player`, ticks at 20 Hz,
and hands the widgets plain `Station`/`Song`/`Broadcast` objects. `p` swaps
the provider in place: the old stream is stopped, workers for the previous
catalogue are cancelled, the hero height and left pane follow the new
provider, and the station list (or schedule) reloads from cache first.
- `beefy_player/providers/bassdrive/api.py` — Icecast's keyless
`status-json.xsl` (mounts, bitrates, listeners, `display-title`) plus the
homepage schedule grid, whose slots carry absolute `<local-time datetime>`
values. A memoised fetch and a lock keep a cold start to one homepage
download; the display-title's show is matched against the running slot for
"live from <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
hero, with per-character style spans.
- `beefy_player/widgets/schedule.py` — the left pane for single-stream
catalogues: the live slot with its `since`/`until` window, then upcoming
shows grouped by local day, with `… +N more` when the pane runs out.
- `beefy_player/player.py` — mpv over a Unix-socket JSON IPC channel. Observes
`pause`/`volume`, handles mirror failover on `end-file`, auto-restarts a dead
engine, and uses `SIGSTOP`/`SIGCONT` when falling back to `ffplay`. On macOS
it also asks mpv to publish Now Playing and maps the media keys onto
`script-message`s that come back over the same IPC socket.
- `beefy_player/spectrum.py` — analyser (44.1 kHz mono s16le from `parec`, or
the device's native float32 rate from the macOS tap). Prefers capturing the
real output: `parec` (PulseAudio/PipeWire monitor) on Linux, or a native
Core Audio process tap on macOS (SYNC, see `macos_tap.py`). Otherwise a
muted low-latency `ffmpeg` decode of the stream (FFT). Hann-windowed
2048-point FFT, log band grouping, auto-gain, fast attack/decay smoothing,
peak hold and waveform downsampling.
- `beefy_player/macos_tap.py` — the macOS output capture, in pure `ctypes`
against Core Audio: `CATapDescription` → `AudioHardwareCreateProcessTap` →
private aggregate device → IOProc. No driver, no helper binary, no ffmpeg.
- `beefy_player/mediasession.py` — picks the desktop media backend (MPRIS on
Linux, Now Playing on macOS) so the app has one platform-neutral interface.
- `beefy_player/mpris.py` — `dbus-next` MPRIS service in its own thread with a
thread-safe command queue back into the UI (only loaded on D-Bus platforms).
- `beefy_player/bandcamp.py` — searches Bandcamp's public autocomplete API for
the now-playing track (artist + track first, then artist + album, then the
album page) and returns a track URL when there is a match.
- `beefy_player/widgets/` — Textual widgets rendered with native ANSI colours;
backgrounds are `ansi_default` (`SGR 49`), so transparent terminals stay
transparent.
### Files - **Artwork shows the mosaic?** Real images are auto-enabled for Kitty, iTerm2
and WezTerm. Force a protocol with `BEEFY_IMAGES=tgp` (Kitty) or
``` `BEEFY_IMAGES=iterm2` (iTerm2/WezTerm); `BEEFY_IMAGES=0` forces the mosaic.
~/.config/beefy-player/config.json favourites, volume, provider, last stations `--diagnose-images` prints what was detected.
~/.cache/beefy-player/channels.json last good SomaFM station list - **No audio?** `mpv` is preferred, `ffplay` works as a fallback. Without
~/.cache/beefy-player/channels-<id>.json last good list, other catalogues either, the UI still runs and tells you. Extra mpv arguments can be injected
~/.cache/beefy-player/schedule-<id>.json last good broadcast schedule with `BEEFY_MPV_ARGS`, e.g. `BEEFY_MPV_ARGS='--audio-device=...'`.
~/.cache/beefy-player/playlists.json resolved stream URLs - **Spectrum says `FFT` instead of `SYNC`?** Output capture is unavailable or
~/.cache/beefy-player/songs/<id>.json SomaFM song history snapshots failed, so the analyser is decoding the stream instead; the bars are still
~/.cache/beefy-player/songs/<p>-<id>.json history for every other catalogue real audio but can drift. Force sync with `--spectrum-source monitor`, or
~/.cache/beefy-player/artwork/<sha1> cached album/channel artwork pick a specific monitor with `BEEFY_SPECTRUM_DEVICE` on Linux. `SIM` means the
``` bars are synthesized.
`last_stations` remembers which station each catalogue was tuned to, so
switching back with `p` lands on the same channel.
These stay in the XDG locations on every platform (macOS included), so the
same dotfiles/config work everywhere; `XDG_CONFIG_HOME`, `XDG_CACHE_HOME` and
`XDG_RUNTIME_DIR` are honoured when set. The app started life as
`somafm-tui`: on first run a `~/.config/somafm-tui/config.json` is copied over
so favourites and volume survive the rename.
## Notes / troubleshooting
- **Artwork shows the mosaic?** Real images are auto-enabled for Kitty,
iTerm2 and WezTerm. Force a protocol with `BEEFY_IMAGES=tgp` (Kitty/TGP) or
`BEEFY_IMAGES=iterm2` (iTerm2/WezTerm); `BEEFY_IMAGES=0` forces the ANSI
mosaic. iTerm2 on macOS is detected from `TERM_PROGRAM`/`LC_TERMINAL`, and
empty or `0` values in `TMUX`/`ZELLIJ`/`STY` (exported by some wrappers) are
ignored so they cannot downgrade it. Kitty inside tmux is attempted through
tmux passthrough (enable `set -g allow-passthrough on`); iTerm2/WezTerm
inside tmux default to the mosaic. Images are cached under
`~/.cache/beefy-player/artwork`.
- **Clock shows UTC?** The clock and song history use the system zone from
`/etc/localtime`; a non-UTC `TZ` overrides it. A container or dev shell can
inherit a build-time `TZ=UTC`, which the app deliberately ignores; bind the
host's `/etc/localtime` or set e.g. `TZ=Europe/Berlin` in the shell that
launches the app.
- **MPRIS says off/error?** No session bus. Run from your desktop session
(the shell profile picks up `$XDG_RUNTIME_DIR/bus`). `dbus-run-session -- uv
run beefy-player` starts a private bus for testing, but desktop widgets won't
see it. macOS does not use MPRIS at all — see “Now Playing” above.
- **No audio?** `beefy-player` needs `mpv` (preferred) or `ffplay`. Without
either, the UI still runs and tells you. Extra mpv args can be injected with
`BEEFY_MPV_ARGS` (e.g. `BEEFY_MPV_ARGS='--audio-device=...'`, which is the
macOS way to choose an output device).
- **Spectrum says `FFT` instead of `SYNC`?** The monitor is unavailable or
failed, or you forced the stream tap. The bars are still real audio, but the
second stream can drift relative to playback. Use `--spectrum-source
monitor` to force sync, or `BEEFY_SPECTRUM_DEVICE=<source>` on Linux to pick
a specific monitor. `SIM` means the bars are synthesized.
- **Diagnose artwork:** `uv run beefy-player --diagnose-images` prints the
detected protocol, widget class, terminal env, dependency versions and cache
path. If `protocol` is `None`, force it with `BEEFY_IMAGES=tgp` (Kitty) or
`BEEFY_IMAGES=iterm2` (iTerm2/WezTerm).
- **`SYNC` but the bars are flat?** mpv may be playing to a different sink (or
directly to ALSA). Point `BEEFY_SPECTRUM_DEVICE` at that sink's monitor, or
use `--spectrum-source stream`.
- **macOS: spectrum stays `FFT`?** Grant your terminal **System Audio - **macOS: spectrum stays `FFT`?** Grant your terminal **System Audio
Recording** permission (*System Settings → Privacy & Security → System Audio Recording** permission under *System Settings → Privacy & Security → System
Recording*), then restart the app; the status bar explains when the tap is Audio Recording*, then restart the app. Also check that mpv is playing to the
silent because of this. Also check that mpv is playing to the default output default output device, since that is what the analyser records.
device, since that is what the tap records. - **macOS: media keys do nothing?** Another app owns the Now Playing session,
- **macOS: media keys do nothing?** Another app currently owns the Now Playing the app was started with `--no-mpris`, or audio fell back to `ffplay` (mpv is
session, or the app was started with `--no-mpris`, or the audio backend fell required for Now Playing).
back to `ffplay` (mpv is required for Now Playing). The status bar shows - **MPRIS says off?** There is no session bus. Run from your desktop session,
`nowplaying on` when the integration is armed. or use `dbus-run-session` for a private bus. macOS does not use MPRIS.
- **macOS: Control Center shows only a title?** mpv publishes the stream's live
“Artist - Title” as the Now Playing title; artist/album fields and artwork are
not filled in by macOS for plain audio streams. The TUI itself always shows
the full metadata and album art.
- **Bandwidth** is unchanged in SYNC mode (the monitor capture creates no
second stream). The FFT fallback opens one extra stream; if you are on a
metered link, use `--spectrum-source monitor` (or `--no-audio` for a UI-only
demo).
- SomaFM is listener-supported: if you enjoy it, [donate](https://somafm.com/support/).
- tilderadio is a volunteer-run community station: [drop by](https://tilderadio.org),
or show up on the [schedule](https://tilderadio.org/schedule/) with a set.
- Bassdrive is free and listener-supported too: [bassdrive.com](https://bassdrive.com)
has the shows, archives and the shop that keeps the relays running.
## NixOS (optional aside) ## Development
None of the above needs Nix. If you *are* on NixOS, `shell.nix` wraps the same
`uv sync` / `uv run` workflow in an FHS environment, because uv installs
manylinux wheels such as NumPy that expect `libstdc++.so.6` in the usual
`/usr/lib` paths:
```bash ```bash
nix-shell # enter the FHS dev shell uv sync # create the environment
uv sync uv run pytest # test suite
uv run beefy-player uvx ruff check . # lint
``` ```
- If you see `libstdc++.so.6: cannot open shared object file`, make sure you Each catalogue is a self-contained provider module under
are inside `nix-shell`, then recreate the venv once with `beefy_player/providers/`; `base.py` documents the interface. `tools/ansi_art.py`
`rm -rf .venv && uv sync`. converts a PNG logo into half-block ANSI art. On NixOS, `shell.nix` provides an
- `nix-shell --run "..."` does not work with FHS `shell.nix` files; enter the FHS shell for the same `uv sync` / `uv run` workflow.
shell interactively and run commands there.
- FHS shells need unprivileged user namespaces. `bwrap: No permissions to
create a new namespace` means they are disabled (for example in a locked-down
container); run the app on the host instead.
## Licence ## Licence
+1 -1
View File
@@ -1,3 +1,3 @@
"""Beefy Player — a flashy terminal player for internet radio.""" """Beefy Player — a flashy terminal player for internet radio."""
__version__ = "1.1.0" __version__ = "1.2.1"
+352 -9
View File
@@ -44,6 +44,10 @@ from .widgets.status import StatusBar
VOLUME_STEP = 5 VOLUME_STEP = 5
#: How close the cursor may get to the end of a paginated list before the next
#: directory page is fetched.
LOAD_MORE_MARGIN = 20
# A theme made only of terminal defaults / ANSI palette entries. Textual's # A theme made only of terminal defaults / ANSI palette entries. Textual's
# built-in themes paint an RGB background and map ANSI names to their own RGB # built-in themes paint an RGB background and map ANSI names to their own RGB
# palette, which breaks transparency and ignores the user's colours. # palette, which breaks transparency and ignores the user's colours.
@@ -146,6 +150,20 @@ class BeefyPlayerApp(App[None]):
self.schedule_loaded = False self.schedule_loaded = False
self.index = 0 self.index = 0
self.query = "" self.query = ""
#: Server-side directory results for ``query``; ``None`` means the
#: view is filtered locally from :attr:`stations`.
self.search_results: list[Station] | None = None
self._search_results_query = ""
#: A server-side search is in flight; the list shows a searching hint.
self.search_pending = False
#: Stations reached through a search or a later directory page. They
#: are kept when a refresh only fetches the first page again.
self._discovered_station_ids: set[str] = set()
#: Directory paging: next offset, whether there are more pages, and
#: whether a page request is already in flight.
self._directory_offset = 0
self._directory_exhausted = True
self._loading_more = False
self.current: Station | None = None self.current: Station | None = None
self.playing_state = "stopped" self.playing_state = "stopped"
self.volume = self.config.volume self.volume = self.config.volume
@@ -209,9 +227,11 @@ class BeefyPlayerApp(App[None]):
self._apply_catalogue_chrome() self._apply_catalogue_chrome()
stations = self.provider.load_cached_stations() stations = self.provider.load_cached_stations()
cached = bool(stations)
if not stations: if not stations:
stations = self.provider.fallback_stations() stations = self.provider.fallback_stations()
self._set_stations(stations) self._set_stations(stations)
self._reset_directory_paging(stations, has_more=cached)
self._select_initial_station() self._select_initial_station()
if self.current is not None: if self.current is not None:
@@ -259,10 +279,35 @@ class BeefyPlayerApp(App[None]):
self.config.visualizer = self.visualizer self.config.visualizer = self.visualizer
self.config.save() self.config.save()
def _find_station(self, station_id: str) -> Station | None:
"""Find a station by id: on air, in the catalogue, or in the search."""
if self.current is not None and self.current.id == station_id:
return self.current
for station in self.stations:
if station.id == station_id:
return station
for station in self.search_results or ():
if station.id == station_id:
return station
return None
def _set_stations(self, stations: list[Station]) -> None: def _set_stations(self, stations: list[Station]) -> None:
if not stations: if not stations:
return return
stations = list(stations)
known = {station.id for station in stations}
for station in self.stations:
if (
station.id in self._discovered_station_ids
and station.id not in known
):
# Keep stations found through search or a later page when a
# refresh only knows the first page.
stations.append(station)
known.add(station.id)
existing = {station.id: station for station in self.stations} existing = {station.id: station for station in self.stations}
if self.current is not None:
existing[self.current.id] = self.current
for station in stations: for station in stations:
previous = existing.get(station.id) previous = existing.get(station.id)
if previous is not None: if previous is not None:
@@ -284,6 +329,33 @@ class BeefyPlayerApp(App[None]):
self._update_artwork() self._update_artwork()
self.query_one(HeaderBar).refresh() self.query_one(HeaderBar).refresh()
def _reset_directory_paging(
self, stations: list[Station], *, has_more: bool = True
) -> None:
self._loading_more = False
if not self.provider.paginated or not has_more:
self._directory_offset = 0
self._directory_exhausted = True
return
# The cached list is in catalogue order, so continue after it.
self._directory_offset = len(stations)
self._directory_exhausted = False
def _append_stations(self, stations: list[Station]) -> int:
"""Add a page to the catalogue, preserving existing station state."""
known = {station.id for station in self.stations}
added = 0
for station in stations:
if station.id in known:
continue
station.favorite = self.config.is_favorite(station.id)
if station.now_playing is None and station.last_playing:
station.now_playing = parse_last_playing(station.last_playing)
self.stations.append(station)
known.add(station.id)
added += 1
return added
def _select_initial_station(self) -> None: def _select_initial_station(self) -> None:
if not self.view: if not self.view:
return return
@@ -307,7 +379,8 @@ class BeefyPlayerApp(App[None]):
break break
def _rebuild_view(self, keep_id: str | None = None) -> None: def _rebuild_view(self, keep_id: str | None = None) -> None:
terms = self.query.casefold().split() query = self.query.strip()
terms = query.casefold().split()
def matches(station: Station) -> bool: def matches(station: Station) -> bool:
if not terms: if not terms:
@@ -321,8 +394,27 @@ class BeefyPlayerApp(App[None]):
if keep_id is None and self.view and 0 <= self.index < len(self.view): if keep_id is None and self.view and 0 <= self.index < len(self.view):
keep_id = self.view[self.index].id keep_id = self.view[self.index].id
if (
terms
and self.search_results is not None
and self._search_results_query == query
):
# The directory matched fields that are not all part of a Station's
# local search text; trust its answer instead of filtering again.
view = list(self.search_results)
else:
view = [station for station in self.stations if matches(station)] view = [station for station in self.stations if matches(station)]
view.sort(key=lambda station: (0 if station.favorite else 1, station.title.casefold())) if self.provider.preserve_order:
# Stable sort: keep the catalogue's popularity order within the
# favourites and everything else.
view.sort(key=lambda station: 0 if station.favorite else 1)
else:
view.sort(
key=lambda station: (
0 if station.favorite else 1,
station.title.casefold(),
)
)
self.view = view self.view = view
if keep_id is not None: if keep_id is not None:
@@ -338,6 +430,39 @@ class BeefyPlayerApp(App[None]):
if self.current is None and view: if self.current is None and view:
self.current = view[self.index] self.current = view[self.index]
def _set_search_results(self, query: str, stations: list[Station]) -> None:
"""Show a server-side directory search in place of the loaded list."""
known = {station.id: station for station in self.stations}
if self.current is not None:
known[self.current.id] = self.current
results: list[Station] = []
for station in stations:
previous = known.get(station.id)
if previous is not None:
# Reuse the catalogue's object so favourite/history changes
# made in the search results show up in the regular list too.
station = previous
else:
station.favorite = self.config.is_favorite(station.id)
if station.now_playing is None and station.last_playing:
station.now_playing = parse_last_playing(station.last_playing)
results.append(station)
self.search_results = results
self._search_results_query = query
self.search_pending = False
self._rebuild_view(
keep_id=self.current.id if self.current is not None else None
)
if self.current is None or not any(
station.id == self.current.id for station in self.view
):
self.index = 0
with contextlib.suppress(NoMatches):
pane = self.query_one(StationList)
pane.set_filter(self.query)
pane.refresh()
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# the catalogue's chrome # the catalogue's chrome
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -362,6 +487,9 @@ class BeefyPlayerApp(App[None]):
def _reset_filter(self) -> None: def _reset_filter(self) -> None:
"""Forget the station filter and put the search box away.""" """Forget the station filter and put the search box away."""
self.query = "" self.query = ""
self.search_results = None
self._search_results_query = ""
self.search_pending = False
search = self.query_one(SearchInput) search = self.query_one(SearchInput)
search.value = "" search.value = ""
search.remove_class("visible") search.remove_class("visible")
@@ -385,6 +513,12 @@ class BeefyPlayerApp(App[None]):
def _tune(self, station: Station) -> None: def _tune(self, station: Station) -> None:
self.current = station self.current = station
self.start_station_id = station.id self.start_station_id = station.id
if not any(existing.id == station.id for existing in self.stations):
# A directory search can reach a station outside the loaded pages;
# keep it in the catalogue so clearing the filter still shows it.
self.stations.append(station)
# Remember it so a refresh that only fetches the first page keeps it.
self._discovered_station_ids.add(station.id)
self.playing_state = "loading" self.playing_state = "loading"
self._set_notice(f"tuning in to {station.title}…", 5) self._set_notice(f"tuning in to {station.title}…", 5)
self._save_config() self._save_config()
@@ -405,7 +539,7 @@ class BeefyPlayerApp(App[None]):
@work(exclusive=True, group="stream", exit_on_error=False) @work(exclusive=True, group="stream", exit_on_error=False)
async def _resolve_and_play(self, station_id: str) -> None: async def _resolve_and_play(self, station_id: str) -> None:
station = next((s for s in self.stations if s.id == station_id), None) station = self._find_station(station_id)
if station is None or self.current is None or self.current.id != station_id: if station is None or self.current is None or self.current.id != station_id:
return return
@@ -440,7 +574,7 @@ class BeefyPlayerApp(App[None]):
@work(exclusive=True, group="history", exit_on_error=False) @work(exclusive=True, group="history", exit_on_error=False)
async def _fetch_history(self, station_id: str) -> None: async def _fetch_history(self, station_id: str) -> None:
self._last_history_fetch = time.monotonic() self._last_history_fetch = time.monotonic()
station = next((s for s in self.stations if s.id == station_id), None) station = self._find_station(station_id)
if station is None or self._http is None: if station is None or self._http is None:
return return
try: try:
@@ -559,7 +693,7 @@ class BeefyPlayerApp(App[None]):
return return
self._bandcamp_urls[f"{station_id}|{song_key}"] = url self._bandcamp_urls[f"{station_id}|{song_key}"] = url
station = next((s for s in self.stations if s.id == station_id), None) station = self._find_station(station_id)
if station is None or self.current is None or self.current.id != station_id: if station is None or self.current is None or self.current.id != station_id:
return return
song = station.now_playing song = station.now_playing
@@ -793,27 +927,179 @@ class BeefyPlayerApp(App[None]):
# workers # workers
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@work(exclusive=True, group="stations-page", exit_on_error=False)
async def _load_more_stations(self) -> None:
try:
if (
not self.provider.paginated
or self._directory_exhausted
or self.offline
or self._http is None
):
return
page = await self.provider.fetch_station_page(
self._http, self._directory_offset
)
except Exception:
self._set_notice("could not load more stations", 4)
return
finally:
self._loading_more = False
self._directory_offset = page.next_offset or 0
self._directory_exhausted = page.next_offset is None
if not self._append_stations(page.stations):
return
keep_id = (
self.view[self.index].id
if self.view and 0 <= self.index < len(self.view)
else None
)
self._rebuild_view(keep_id=keep_id)
with contextlib.suppress(NoMatches):
pane = self.query_one(StationList)
pane.set_filter(self.query)
pane.refresh()
def _maybe_load_more_stations(self) -> None:
"""Queue the next directory page when the cursor nears the end."""
if (
not self.provider.paginated
or self._loading_more
or self._directory_exhausted
or self.offline
or not self.view
or self.query.strip()
):
return
if self.index < len(self.view) - LOAD_MORE_MARGIN:
return
self._loading_more = True
self._set_notice("loading more stations…", 3)
self._load_more_stations()
@work(exclusive=True, group="search", exit_on_error=False)
async def _search_directory(self, query: str) -> None:
"""Debounced server-side search, for catalogues that support it."""
text = query.strip()
if (
not text
or not self.provider.server_search
or self.offline
or self._http is None
):
self.search_pending = False
return
# A new keystroke starts a new exclusive worker and cancels this sleep.
await asyncio.sleep(0.25)
if text != self.query.strip():
return
try:
stations = await self.provider.search_stations(self._http, text)
except Exception:
if text == self.query.strip():
self.search_pending = False
self._set_notice(
"directory search failed — filtering the loaded stations", 4
)
self._refresh_pane()
return
if text != self.query.strip():
return
self._set_search_results(text, stations)
async def _load_favorite_stations(self) -> None:
"""Fetch favourites that are not on the currently loaded page."""
if self.offline or self._http is None or not self.provider.paginated:
return
known = {station.id for station in self.stations}
missing = [
station_id
for station_id in self.config.favorites
if station_id not in known
]
if not missing:
return
try:
stations = await self.provider.fetch_stations_by_ids(
self._http, missing
)
except Exception:
return
added = 0
for station in stations:
if any(existing.id == station.id for existing in self.stations):
continue
if not self.config.is_favorite(station.id):
continue
previous = next(
(
searched
for searched in (self.search_results or ())
if searched.id == station.id
),
None,
)
if previous is not None:
# A search result and a favourite lookup can race; keep one
# object so favourite/history changes are shared.
station = previous
station.favorite = True
self.stations.append(station)
self._discovered_station_ids.add(station.id)
added += 1
if not added:
return
keep_id = (
self.view[self.index].id
if self.view and 0 <= self.index < len(self.view)
else None
)
self._rebuild_view(keep_id=keep_id)
with contextlib.suppress(NoMatches):
pane = self.query_one(StationList)
pane.set_filter(self.query)
pane.refresh()
@work(exclusive=True, group="stations", exit_on_error=False) @work(exclusive=True, group="stations", exit_on_error=False)
async def _refresh_stations(self) -> None: async def _refresh_stations(self) -> None:
if self.offline or self._http is None: if self.offline or self._http is None:
return return
try: try:
if self.provider.paginated:
page = await self.provider.fetch_station_page(self._http, 0)
stations = page.stations
next_offset = page.next_offset
else:
stations = await self.provider.fetch_stations(self._http) stations = await self.provider.fetch_stations(self._http)
next_offset = None
if not stations:
raise RuntimeError("the catalogue returned no stations")
except Exception: except Exception:
if not self.stations: if not self.stations:
self._set_stations(self.provider.fallback_stations()) self._set_stations(self.provider.fallback_stations())
self._set_notice("offline — using the built-in station list", 8) self._set_notice("offline — using the built-in station list", 8)
else: else:
self._set_notice("station refresh failed — using the cached list", 6) self._set_notice("station refresh failed — using the cached list", 6)
# The full directory may be unreachable while a favourite lookup
# still works; show those rather than hiding them until a restart.
await self._load_favorite_stations()
return return
before = self.current.id if self.current is not None else None before = self.current.id if self.current is not None else None
self._set_stations(stations) self._set_stations(stations)
if before is not None: if before is not None:
self._select_station_id(before) self._select_station_id(before)
self._directory_offset = next_offset or 0
self._directory_exhausted = next_offset is None
self._loading_more = False
self._set_notice( self._set_notice(
"1 station" if len(stations) == 1 else f"{len(stations)} stations", 3 "1 station" if len(stations) == 1 else f"{len(stations)} stations", 3
) )
self.query_one(StationList).refresh() self.query_one(StationList).refresh()
await self._load_favorite_stations()
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# actions # actions
@@ -830,6 +1116,7 @@ class BeefyPlayerApp(App[None]):
return return
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()
self._maybe_load_more_stations()
def _page(self, delta: int) -> None: def _page(self, delta: int) -> None:
if not self.view: if not self.view:
@@ -837,6 +1124,7 @@ class BeefyPlayerApp(App[None]):
page = max(1, self.query_one(StationList).size.height - 1) page = max(1, self.query_one(StationList).size.height - 1)
self.index = max(0, min(len(self.view) - 1, self.index + delta * page)) self.index = max(0, min(len(self.view) - 1, self.index + delta * page))
self.query_one(StationList).refresh() self.query_one(StationList).refresh()
self._maybe_load_more_stations()
def action_page_up(self) -> None: def action_page_up(self) -> None:
self._page(-1) self._page(-1)
@@ -855,6 +1143,7 @@ class BeefyPlayerApp(App[None]):
return return
self.index = len(self.view) - 1 self.index = len(self.view) - 1
self.query_one(StationList).refresh() self.query_one(StationList).refresh()
self._maybe_load_more_stations()
def action_tune(self) -> None: def action_tune(self) -> None:
if not self.view: if not self.view:
@@ -914,6 +1203,21 @@ class BeefyPlayerApp(App[None]):
return return
added = self.config.toggle_favorite(station.id) added = self.config.toggle_favorite(station.id)
station.favorite = added station.favorite = added
known = False
for existing in self.stations:
if existing.id == station.id:
# A search copy and the catalogue copy can briefly coexist;
# keep both in step so clearing the filter cannot resurrect
# the old favourite state.
existing.favorite = added
known = True
break
if added:
# A favourite reached through a directory search must join the
# catalogue, or clearing the filter would hide it again.
self._discovered_station_ids.add(station.id)
if not known:
self.stations.append(station)
self._save_config() self._save_config()
self._rebuild_view(keep_id=station.id) self._rebuild_view(keep_id=station.id)
self._set_notice(("★ added " if added else "☆ removed ") + station.title, 3) self._set_notice(("★ added " if added else "☆ removed ") + station.title, 3)
@@ -932,9 +1236,12 @@ class BeefyPlayerApp(App[None]):
search.cursor_position = len(search.value) search.cursor_position = len(search.value)
def action_escape(self) -> None: def action_escape(self) -> None:
if self.query: search = self.query_one(SearchInput)
if self.query and not search.has_class("visible"):
# The filter is already sticky: a second escape clears it.
self._close_search(clear=True) self._close_search(clear=True)
else: else:
# Put the search box away but keep the filter on the station list.
self._close_search(clear=False) self._close_search(clear=False)
def _close_search(self, clear: bool = False) -> None: def _close_search(self, clear: bool = False) -> None:
@@ -942,6 +1249,9 @@ class BeefyPlayerApp(App[None]):
search.remove_class("visible") search.remove_class("visible")
if clear: if clear:
self.query = "" self.query = ""
self.search_results = None
self._search_results_query = ""
self.search_pending = False
search.value = "" search.value = ""
search.disabled = True search.disabled = True
self.set_focus(None) self.set_focus(None)
@@ -949,6 +1259,7 @@ class BeefyPlayerApp(App[None]):
self.query_one(StationList).set_filter(self.query) self.query_one(StationList).set_filter(self.query)
self.query_one(StationList).refresh() self.query_one(StationList).refresh()
self.query_one(StagePanel).refresh_stage() self.query_one(StagePanel).refresh_stage()
self._maybe_load_more_stations()
def action_visualizer(self) -> None: def action_visualizer(self) -> None:
modes = list(config.VISUALIZERS) modes = list(config.VISUALIZERS)
@@ -979,6 +1290,9 @@ class BeefyPlayerApp(App[None]):
self._last_history_fetch = 0.0 self._last_history_fetch = 0.0
self._bandcamp_key = "" self._bandcamp_key = ""
self._bandcamp_urls.clear() self._bandcamp_urls.clear()
with contextlib.suppress(Exception):
self.workers.cancel_group(self, "stations-page")
self._loading_more = False
self._set_notice("refreshing…", 2) self._set_notice("refreshing…", 2)
if self.offline: if self.offline:
self._set_notice("offline mode — nothing to refresh", 3) self._set_notice("offline mode — nothing to refresh", 3)
@@ -1009,7 +1323,16 @@ class BeefyPlayerApp(App[None]):
self.query_one(ArtPane).resume() self.query_one(ArtPane).resume()
def _cancel_workers(self) -> None: def _cancel_workers(self) -> None:
for group in ("stream", "history", "stations", "schedule", "bandcamp", "artwork"): for group in (
"stream",
"history",
"search",
"stations",
"stations-page",
"schedule",
"bandcamp",
"artwork",
):
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
self.workers.cancel_group(self, group) self.workers.cancel_group(self, group)
@@ -1039,6 +1362,10 @@ class BeefyPlayerApp(App[None]):
self.stations = [] self.stations = []
self.view = [] self.view = []
self.index = 0 self.index = 0
self.search_results = None
self._search_results_query = ""
self.search_pending = False
self._discovered_station_ids.clear()
self.current = None self.current = None
self.schedule = [] self.schedule = []
self.schedule_loaded = False self.schedule_loaded = False
@@ -1055,8 +1382,12 @@ class BeefyPlayerApp(App[None]):
self._apply_catalogue_chrome() self._apply_catalogue_chrome()
with contextlib.suppress(NoMatches): with contextlib.suppress(NoMatches):
self._reset_filter() self._reset_filter()
stations = provider.load_cached_stations() or provider.fallback_stations() stations = provider.load_cached_stations()
cached = bool(stations)
if not stations:
stations = provider.fallback_stations()
self._set_stations(stations) self._set_stations(stations)
self._reset_directory_paging(stations, has_more=cached)
self._select_initial_station() self._select_initial_station()
self._update_media() self._update_media()
self._update_artwork() self._update_artwork()
@@ -1096,15 +1427,27 @@ class BeefyPlayerApp(App[None]):
if event.input.id != "search": if event.input.id != "search":
return return
self.query = event.value self.query = event.value
self.search_results = None
self._search_results_query = ""
self.search_pending = (
self.provider.server_search
and not self.offline
and bool(event.value.strip())
)
self._rebuild_view() self._rebuild_view()
self.query_one(StationList).set_filter(self.query) self.query_one(StationList).set_filter(self.query)
self.query_one(StationList).refresh() self.query_one(StationList).refresh()
if self.provider.server_search:
self._search_directory(event.value)
self._maybe_load_more_stations()
def on_input_submitted(self, event: Input.Submitted) -> None: def on_input_submitted(self, event: Input.Submitted) -> None:
if event.input.id != "search": if event.input.id != "search":
return return
station = self.view[self.index] if self.view else None station = self.view[self.index] if self.view else None
self._close_search(clear=True) # Enter tunes, and leaves the filter in place exactly like the first
# escape does; a second escape returns to the regular station list.
self._close_search(clear=False)
if station is not None: if station is not None:
self._tune(station) self._tune(station)
+31
View File
@@ -75,6 +75,32 @@ PALETTES: dict[str, tuple[str, ...]] = {
"mono": ("bright_black", "white", "bright_white"), "mono": ("bright_black", "white", "bright_white"),
} }
#: The stock names from dark to bright. These are xterm's default values in
#: Rec. 601 luma order — the closest thing to a stock answer. A theme may
#: disagree about the exact numbers, but the ordering is what the frame fades
#: need: a ladder is a *hue* walk, so where it ends says nothing about which
#: end is brighter.
BRIGHTNESS: tuple[str, ...] = (
"black",
"blue",
"red",
"bright_red",
"magenta",
"bright_magenta",
"bright_blue",
"green",
"bright_black",
"cyan",
"bright_green",
"bright_cyan",
"yellow",
"white",
"bright_yellow",
"bright_white",
)
_RANK = {name: rank for rank, name in enumerate(BRIGHTNESS)}
#: Cycle order for the palette key. #: Cycle order for the palette key.
PALETTE_ORDER: tuple[str, ...] = tuple(PALETTES) PALETTE_ORDER: tuple[str, ...] = tuple(PALETTES)
@@ -86,3 +112,8 @@ def palette_stops(name: str | None) -> tuple[str, ...]:
"""The ladder for ``name``, falling back to the default palette.""" """The ladder for ``name``, falling back to the default palette."""
key = (name or "").strip().lower() key = (name or "").strip().lower()
return PALETTES.get(key, PALETTES[DEFAULT_PALETTE]) return PALETTES.get(key, PALETTES[DEFAULT_PALETTE])
def brightness(colour: str) -> int:
"""How bright a stock name is: 0 (black) to 15 (bright white)."""
return _RANK.get(colour.strip().lower(), 0)
+56
View File
@@ -18,6 +18,7 @@ import asyncio
import re import re
import time import time
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any from typing import Any
from urllib.parse import quote from urllib.parse import quote
@@ -29,6 +30,7 @@ from ..models import Broadcast, Song, Station
__all__ = [ __all__ = [
"PLAYLIST_TTL", "PLAYLIST_TTL",
"RadioProvider", "RadioProvider",
"StationPage",
"fetch_json", "fetch_json",
"is_direct_stream", "is_direct_stream",
"is_playlist", "is_playlist",
@@ -62,6 +64,15 @@ def reset_playlist_cache() -> None:
_PLAYLIST_CACHE = None _PLAYLIST_CACHE = None
@dataclass(slots=True)
class StationPage:
"""One page of a large catalogue, in the catalogue's own order."""
stations: list[Station]
#: Offset to request next, or ``None`` when the catalogue is exhausted.
next_offset: int | None = None
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# the interface # the interface
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -114,6 +125,20 @@ class RadioProvider(ABC):
#: stations that would rather not be scraped). #: stations that would rather not be scraped).
bandcamp: bool = True bandcamp: bool = True
#: The catalogue can search its whole directory on the server. When true,
#: the app calls :meth:`search_stations` as the filter changes instead of
#: only filtering the (possibly small) list from :meth:`fetch_stations`.
server_search: bool = False
#: The catalogue is larger than one page. When true, the app fetches the
#: first page with :meth:`fetch_station_page` and asks for the next one as
#: the station list nears its end.
paginated: bool = False
#: Keep the catalogue's own ordering (popularity, schedule, ...) instead of
#: sorting the station list alphabetically.
preserve_order: bool = False
# -- catalogue ------------------------------------------------------- # # -- catalogue ------------------------------------------------------- #
@abstractmethod @abstractmethod
@@ -128,6 +153,37 @@ class RadioProvider(ABC):
def fallback_stations(self) -> list[Station]: def fallback_stations(self) -> list[Station]:
"""A bundled list, so a fresh offline start still works.""" """A bundled list, so a fresh offline start still works."""
async def search_stations(
self, client: httpx.AsyncClient, query: str
) -> list[Station]:
"""Search the catalogue for *query*.
Only called when :attr:`server_search` is true. Providers that search
locally leave this alone; the app filters the loaded list itself.
"""
return []
async def fetch_station_page(
self, client: httpx.AsyncClient, offset: int = 0
) -> StationPage:
"""One page of a paginated catalogue.
Only called when :attr:`paginated` is true. ``offset`` is the value
from the previous page's :attr:`StationPage.next_offset`.
"""
return StationPage(stations=[], next_offset=None)
async def fetch_stations_by_ids(
self, client: httpx.AsyncClient, station_ids: list[str]
) -> list[Station]:
"""Fetch specific stations by id.
Used for favourites that are not on the first page of a paginated
catalogue. Providers that return their whole catalogue at once can
leave this alone.
"""
return []
# -- metadata -------------------------------------------------------- # # -- metadata -------------------------------------------------------- #
@abstractmethod @abstractmethod
@@ -1,8 +1,9 @@
"""Radio Browser: a directory of the community's stations rather than a station. """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. The most-clicked working relays are loaded a page at a time, and the rest of
There is no now-playing API, so this catalogue offers breadth, not history: pick the 60,000-station directory is searched on the server as you type. There is no
a station and the stream speaks for itself. now-playing API, so this catalogue offers breadth, not history: pick a station
and the stream speaks for itself.
""" """
from __future__ import annotations from __future__ import annotations
@@ -11,7 +12,7 @@ import httpx
from ... import config from ... import config
from ...models import Song, Station from ...models import Song, Station
from ..base import RadioProvider from ..base import RadioProvider, StationPage
from . import api, fallback from . import api, fallback
from . import hero as hero_art from . import hero as hero_art
@@ -23,8 +24,8 @@ class RadioBrowserProvider(RadioProvider):
name = "Radio Browser" name = "Radio Browser"
homepage = "radio-browser.info" homepage = "radio-browser.info"
brand = "RADIO BROWSER" brand = "RADIO BROWSER"
tagline = "community directory · 50,000 stations" tagline = "community directory · live search"
station_source = "radio-browser.info (top 300)" station_source = "radio-browser.info (paged + search)"
history_source = "" history_source = ""
hero = hero_art.HERO hero = hero_art.HERO
@@ -33,6 +34,14 @@ class RadioBrowserProvider(RadioProvider):
# A directory has no show schedule and no Bandcamp worth scraping. # A directory has no show schedule and no Bandcamp worth scraping.
bandcamp = False bandcamp = False
# The full directory is much larger than the list fetched at startup; the
# app asks the API when the filter changes.
server_search = True
# The directory is loaded a page at a time, in click-count order.
paginated = True
preserve_order = True
@property @property
def user_agent(self) -> str: def user_agent(self) -> str:
return f"{config.USER_AGENT}; +https://www.radio-browser.info" return f"{config.USER_AGENT}; +https://www.radio-browser.info"
@@ -40,6 +49,21 @@ class RadioBrowserProvider(RadioProvider):
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]: async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
return await api.fetch_stations(client) return await api.fetch_stations(client)
async def fetch_station_page(
self, client: httpx.AsyncClient, offset: int = 0
) -> StationPage:
return await api.fetch_station_page(client, offset)
async def fetch_stations_by_ids(
self, client: httpx.AsyncClient, station_ids: list[str]
) -> list[Station]:
return await api.fetch_stations_by_ids(client, station_ids)
async def search_stations(
self, client: httpx.AsyncClient, query: str
) -> list[Station]:
return await api.search_stations(client, query)
def load_cached_stations(self) -> list[Station]: def load_cached_stations(self) -> list[Station]:
return api.load_cached_stations() return api.load_cached_stations()
+141 -16
View File
@@ -2,13 +2,15 @@
``https://de1.api.radio-browser.info/json/stations/search`` is keyless, CORS-open ``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 — 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 there is no per-station history — so this catalogue is a *directory*: the most
hundred of the most-clicked working stations, ready to tune into. Whatever the clicked working stations, a page at a time. Whatever the relay puts in its
relay puts in its stream is what you hear; the app has no metadata to show. stream is what you hear; the app has no metadata to show.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import re
from typing import Any from typing import Any
from urllib.parse import urlencode from urllib.parse import urlencode
@@ -16,18 +18,34 @@ import httpx
from ... import config from ... import config
from ...models import Station, to_int from ...models import Station, to_int
from ..base import fetch_json from ..base import StationPage, fetch_json
API_URL = "https://de1.api.radio-browser.info/json/stations/search" API_URL = "https://de1.api.radio-browser.info/json/stations/search"
BY_UUID_URL = "https://de1.api.radio-browser.info/json/stations/byuuid"
#: Three hundred is a comfortable directory: enough to browse, quick to fetch. #: Stations per directory page. The app loads the first page at startup and
STATION_LIMIT = 300 #: the next ones as the station list nears its end.
PAGE_SIZE = 100
#: Results per field for a directory search. The four fields are merged.
SEARCH_LIMIT = 100
#: The search endpoint has no full-text parameter, so a free-text query is
#: sent once per field and the answers are merged.
_SEARCH_FIELDS = ("name", "tag", "country", "language")
#: Radio Browser ids are UUIDs; anything else is another catalogue's favourite.
_UUID_PATTERN = re.compile(
r"^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$"
)
#: How many UUIDs to ask for in one ``byuuid`` request.
LOOKUP_BATCH = 50
_PARAMS = { _PARAMS = {
"hidebroken": "true", "hidebroken": "true",
"order": "clickcount", "order": "clickcount",
"reverse": "true", "reverse": "true",
"limit": str(STATION_LIMIT),
} }
_CODECS = {"mp3": "mp3", "aac": "aac", "aac+": "aacp", "ogg": "ogg", "opus": "opus"} _CODECS = {"mp3": "mp3", "aac": "aac", "aac+": "aacp", "ogg": "ogg", "opus": "opus"}
@@ -81,7 +99,7 @@ def station_from_entry(entry: dict[str, Any]) -> Station | None:
) )
def parse_stations(payload: Any) -> list[Station]: def parse_stations(payload: Any, *, sort: bool = True) -> list[Station]:
if not isinstance(payload, list): if not isinstance(payload, list):
return [] return []
stations: list[Station] = [] stations: list[Station] = []
@@ -92,22 +110,129 @@ def parse_stations(payload: Any) -> list[Station]:
continue continue
seen.add(station.id) seen.add(station.id)
stations.append(station) stations.append(station)
if sort:
stations.sort(key=lambda station: station.title.casefold()) stations.sort(key=lambda station: station.title.casefold())
return stations return stations
async def fetch_stations(client: httpx.AsyncClient) -> list[Station]: def _page_url(offset: int) -> str:
url = f"{API_URL}?{urlencode(_PARAMS)}" params = {**_PARAMS, "limit": str(PAGE_SIZE), "offset": str(offset)}
return f"{API_URL}?{urlencode(params)}"
async def fetch_station_page(
client: httpx.AsyncClient, offset: int = 0
) -> StationPage:
"""One page of the directory, most-clicked first."""
try: try:
payload = await fetch_json(client, url) payload = await fetch_json(client, _page_url(offset))
except RuntimeError as exc: except RuntimeError as exc:
raise RuntimeError(f"could not fetch the Radio Browser directory: {exc}") from None raise RuntimeError(f"could not fetch the Radio Browser directory: {exc}") from None
stations = parse_stations(payload)
if not stations: stations = parse_stations(payload, sort=False)
raise RuntimeError("Radio Browser returned no stations") rows = len(payload) if isinstance(payload, list) else 0
next_offset = offset + PAGE_SIZE if rows >= PAGE_SIZE else None
if offset == 0 and stations and isinstance(payload, list):
config.write_json(config.channels_cache_path("radiobrowser"), payload) config.write_json(config.channels_cache_path("radiobrowser"), payload)
return stations return StationPage(stations=stations, next_offset=next_offset)
async def fetch_stations(client: httpx.AsyncClient) -> list[Station]:
page = await fetch_station_page(client, 0)
if not page.stations:
raise RuntimeError("Radio Browser returned no stations")
return page.stations
def load_cached_stations() -> list[Station]: def load_cached_stations() -> list[Station]:
return parse_stations(config.read_json(config.channels_cache_path("radiobrowser"))) return parse_stations(
config.read_json(config.channels_cache_path("radiobrowser")), sort=False
)
async def _search_field(
client: httpx.AsyncClient, query: str, field: str
) -> list[Station]:
params = {**_PARAMS, "limit": str(SEARCH_LIMIT), field: query}
payload = await fetch_json(client, f"{API_URL}?{urlencode(params)}")
return parse_stations(payload, sort=False)
async def search_stations(client: httpx.AsyncClient, query: str) -> list[Station]:
"""Search the directory by name, tag, country and language.
The API has no single full-text search, so the query is sent once per field
and the results are merged. A station that matches a genre rarely has that
genre in its name, and vice versa. Fields that fail are ignored as long as
one of them still answers; only a complete failure is raised.
"""
text = query.strip()
if not text:
return []
batches = await asyncio.gather(
*(_search_field(client, text, field) for field in _SEARCH_FIELDS),
return_exceptions=True,
)
stations: list[Station] = []
seen: set[str] = set()
failures = 0
for batch in batches:
if isinstance(batch, BaseException):
failures += 1
continue
for station in batch:
if station.id in seen:
continue
seen.add(station.id)
stations.append(station)
if failures == len(batches):
raise RuntimeError("could not search the Radio Browser directory")
return stations
async def _fetch_by_uuids(
client: httpx.AsyncClient, station_ids: list[str]
) -> list[Station]:
params = urlencode({"uuids": ",".join(station_ids)}, safe=",")
payload = await fetch_json(client, f"{BY_UUID_URL}?{params}")
return parse_stations(payload, sort=False)
async def fetch_stations_by_ids(
client: httpx.AsyncClient, station_ids: list[str]
) -> list[Station]:
"""Look up specific stations, for favourites outside the first page."""
ids = list(
dict.fromkeys(
station_id.strip()
for station_id in station_ids
if _UUID_PATTERN.match(station_id.strip())
)
)
if not ids:
return []
batches = [
ids[index : index + LOOKUP_BATCH]
for index in range(0, len(ids), LOOKUP_BATCH)
]
results = await asyncio.gather(
*(_fetch_by_uuids(client, batch) for batch in batches),
return_exceptions=True,
)
stations: list[Station] = []
seen: set[str] = set()
for batch in results:
if isinstance(batch, BaseException):
continue
for station in batch:
if station.id in seen:
continue
seen.add(station.id)
stations.append(station)
return stations
+27 -13
View File
@@ -2,8 +2,9 @@
The panes draw their own frames instead of using Textual's ``border`` so the The panes draw their own frames instead of using Textual's ``border`` so the
corners can be coloured. Two of them — the top right and the bottom left — corners can be coloured. Two of them — the top right and the bottom left —
run a short fade out of the palette's hot end and back into the border grey; run a short fade out of the palette's hot end and back into the border grey,
everything else, including the other two corners, is that grey. brightest colour at the corner; everything else, including the other two
corners, is that grey.
The ladder is whatever palette the app is on (``c`` cycles them), which is why The ladder is whatever palette the app is on (``c`` cycles them), which is why
every function here takes ``stops`` rather than reading a module constant. every function here takes ``stops`` rather than reading a module constant.
@@ -11,12 +12,14 @@ every function here takes ``stops`` rather than reading a module constant.
from __future__ import annotations from __future__ import annotations
from functools import cache
from rich.cells import cell_len from rich.cells import cell_len
from rich.console import Group, RenderableType from rich.console import Group, RenderableType
from rich.text import Text from rich.text import Text
from textual.widget import Widget from textual.widget import Widget
from ..palettes import DEFAULT_PALETTE, PALETTES from ..palettes import DEFAULT_PALETTE, PALETTES, brightness
from .common import active_stops from .common import active_stops
#: The frame itself: one grey, the same grey as the scrollbar thumb. #: The frame itself: one grey, the same grey as the scrollbar thumb.
@@ -26,20 +29,32 @@ EDGE = "bright_black"
FADE_CELLS = 4 FADE_CELLS = 4
@cache
def fade_ramp(stops: tuple[str, ...]) -> tuple[str, ...]:
"""The corner ramp: brightest cell first, border grey past its end.
The colours come from the palette's hot end, but a ladder is a hue walk,
not a brightness walk: ``spectrum`` ends on ``red``, which is dimmer than
the ``yellow`` just before it, so painting the ladder backwards put the
dim colour at the corner and the glow out on the edge. Sorting the hot
end by brightness keeps the corner the brightest cell and dims outwards,
whatever theme sits behind the names.
"""
hot = [name for name in stops if name != EDGE][-FADE_CELLS:]
return tuple(sorted(hot, key=brightness, reverse=True))
def corner_fade(distance: int, stops: tuple[str, ...] = ()) -> str: def corner_fade(distance: int, stops: tuple[str, ...] = ()) -> str:
"""Colour ``distance`` cells out from a corner; 0 is the corner glyph. """Colour ``distance`` cells out from a corner; 0 is the corner glyph.
The fade is short — four cells — and walks backwards down the palette from The fade is short — four cells — and runs from the brightest colour the
its hot end, so the corner glows in the palette's brightest colour and then palette's hot end offers back into the plain border grey.
hands over to the plain border grey.
""" """
if not stops: if not stops:
stops = PALETTES[DEFAULT_PALETTE] stops = PALETTES[DEFAULT_PALETTE]
ramp = fade_ramp(tuple(stops))
distance = max(0, distance) distance = max(0, distance)
if distance >= FADE_CELLS: return ramp[distance] if distance < len(ramp) else EDGE
return EDGE
index = len(stops) - 1 - distance
return stops[index] if index >= 0 else EDGE
def inner_size(width: int, height: int) -> tuple[int, int]: def inner_size(width: int, height: int) -> tuple[int, int]:
@@ -119,7 +134,6 @@ def wrap(
width = max(4, width) width = max(4, width)
height = max(2, height) height = max(2, height)
inner_width, inner_height = inner_size(width, height) inner_width, inner_height = inner_size(width, height)
rows = len(lines[:inner_height])
framed: list[Text] = [top(width, title, stops)] framed: list[Text] = [top(width, title, stops)]
for index, line in enumerate(lines[:inner_height]): for index, line in enumerate(lines[:inner_height]):
@@ -129,12 +143,12 @@ def wrap(
line = line.copy() line = line.copy()
line.truncate(inner_width, overflow="ellipsis") line.truncate(inner_width, overflow="ellipsis")
row = Text(no_wrap=True) row = Text(no_wrap=True)
row.append_text(side_row(index, rows, "left", stops)) row.append_text(side_row(index, inner_height, "left", stops))
row.append_text(line) row.append_text(line)
pad = inner_width - cell_len(line.plain) pad = inner_width - cell_len(line.plain)
if pad > 0: if pad > 0:
row.append(" " * pad) row.append(" " * pad)
row.append_text(side_row(index, rows, "right", stops)) row.append_text(side_row(index, inner_height, "right", stops))
framed.append(row) framed.append(row)
while len(framed) < height - 1: while len(framed) < height - 1:
index = len(framed) - 1 index = len(framed) - 1
+1 -1
View File
@@ -35,7 +35,7 @@ HELP_ROWS: tuple[tuple[str, str], ...] = (
("m", "mute / unmute"), ("m", "mute / unmute"),
("f", "favourite — favourites float to the top"), ("f", "favourite — favourites float to the top"),
("/", "filter stations by title, genre or description"), ("/", "filter stations by title, genre or description"),
("esc", "clear the filter / close overlays"), ("esc", "close the search box; esc again clears the filter"),
( (
"v", "v",
"cycle visualiser: mirror → bars → wave → waterfall → radial → braille", "cycle visualiser: mirror → bars → wave → waterfall → radial → braille",
+3
View File
@@ -88,6 +88,9 @@ class StationList(Widget):
view = app.view view = app.view
if not view: if not view:
if getattr(app, "search_pending", False):
message = "searching the directory…"
else:
message = "no stations match" if app.query else "loading stations…" message = "no stations match" if app.query else "loading stations…"
return frame.wrap( return frame.wrap(
[Text(truncate(message, inner_width), style="dim italic")], [Text(truncate(message, inner_width), style="dim italic")],
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "beefy-player" name = "beefy-player"
version = "1.1.0" version = "1.2.1"
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)" 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"
+314 -3
View File
@@ -8,7 +8,8 @@ import pytest
from beefy_player import config from beefy_player import config
from beefy_player.app import BeefyPlayerApp from beefy_player.app import BeefyPlayerApp
from beefy_player.models import Song from beefy_player.models import Song, Station
from beefy_player.providers.base import StationPage
from beefy_player.widgets.art import ArtPane from beefy_player.widgets.art import ArtPane
from beefy_player.widgets.help import HelpScreen from beefy_player.widgets.help import HelpScreen
from beefy_player.widgets.stage import StagePanel from beefy_player.widgets.stage import StagePanel
@@ -70,8 +71,10 @@ async def test_boot_navigation_filter_favourite_visualizer_help() -> None:
await pilot.pause() await pilot.pause()
assert app.current is not None assert app.current is not None
assert app.current.id == "defcon" assert app.current.id == "defcon"
assert app.query == "" assert app.query == "defcon"
assert len(app.view) == len(app.stations) assert [station.id for station in app.view] == ["defcon"]
assert not search.has_class("visible")
assert app.focused is None
assert app.playing_state in ("stopped", "error") assert app.playing_state in ("stopped", "error")
app.action_favorite() app.action_favorite()
@@ -100,6 +103,314 @@ async def test_boot_navigation_filter_favourite_visualizer_help() -> None:
assert "DEF CON" in text.upper() assert "DEF CON" in text.upper()
@pytest.mark.asyncio
async def test_escape_leaves_the_filter_sticky_and_a_second_escape_clears_it() -> None:
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
await pilot.press("slash")
for char in "defcon":
await pilot.press(char)
await pilot.pause()
search = app.query_one("#search")
assert search.has_class("visible")
assert app.query == "defcon"
assert [station.id for station in app.view] == ["defcon"]
await pilot.press("escape")
await pilot.pause()
assert app.query == "defcon"
assert search.value == "defcon"
assert not search.has_class("visible")
assert app.focused is None
assert [station.id for station in app.view] == ["defcon"]
assert "defcon" in app.query_one(StationList).border_title
app.action_favorite()
await pilot.pause()
assert "defcon" in app.config.favorites
assert app.query == "defcon"
assert [station.id for station in app.view] == ["defcon"]
await pilot.press("escape")
await pilot.pause()
assert app.query == ""
assert search.value == ""
assert len(app.view) == len(app.stations)
@pytest.mark.asyncio
async def test_enter_keeps_the_filter_sticky_like_the_first_escape() -> None:
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
await pilot.press("slash")
for char in "defcon":
await pilot.press(char)
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
search = app.query_one("#search")
assert app.current is not None
assert app.current.id == "defcon"
assert app.query == "defcon"
assert [station.id for station in app.view] == ["defcon"]
assert not search.has_class("visible")
await pilot.press("escape")
await pilot.pause()
assert app.query == ""
assert len(app.view) == len(app.stations)
@pytest.mark.asyncio
async def test_directory_search_uses_the_server_not_the_loaded_list(monkeypatch) -> None:
app = BeefyPlayerApp(
provider="radiobrowser", autoplay=False, offline=True, audio=False
)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
app.offline = False
found = Station(
id="deep-jazz",
title="Deep Jazz",
description="France · french",
genre="jazz",
provider="radiobrowser",
)
queries: list[str] = []
async def fake_search(self, client, query):
queries.append(query)
return [found]
monkeypatch.setattr(type(app.provider), "search_stations", fake_search)
app.query = "jazz"
app._rebuild_view()
assert not any(station.id == "deep-jazz" for station in app.view)
worker = app._search_directory("jazz")
await worker.wait()
await pilot.pause()
assert queries == ["jazz"]
assert [station.id for station in app.view] == ["deep-jazz"]
assert app._find_station("deep-jazz") is found
app._tune(found)
assert any(station.id == "deep-jazz" for station in app.stations)
app._set_stations(
[Station(id="other", title="Other", provider="radiobrowser")]
)
assert app.current.id == "deep-jazz"
assert any(station.id == "deep-jazz" for station in app.stations)
app.query = ""
app.search_results = None
app._search_results_query = ""
app._rebuild_view()
assert len(app.view) == len(app.stations)
@pytest.mark.asyncio
async def test_a_refresh_replaces_a_fallback_station() -> None:
app = BeefyPlayerApp(
provider="radiobrowser", autoplay=False, offline=True, audio=False
)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
assert app.current is not None
assert app.current.id.startswith("fallback-")
app._set_stations(
[Station(id="real", title="Real Station", provider="radiobrowser")]
)
assert app.current.id == "real"
assert [station.id for station in app.stations] == ["real"]
@pytest.mark.asyncio
async def test_favoriting_a_loaded_station_from_search_updates_it_in_place() -> None:
app = BeefyPlayerApp(
provider="radiobrowser", autoplay=False, offline=True, audio=False
)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
existing = app.stations[0]
search_copy = Station(
id=existing.id, title=existing.title, provider="radiobrowser"
)
app.query = "jazz"
app._set_search_results("jazz", [search_copy])
assert app.view[0] is existing
app.index = 0
app.action_favorite()
await pilot.pause()
assert existing.favorite is True
assert app.config.is_favorite(existing.id)
app._close_search(clear=True)
await pilot.pause()
assert app.view[0].id == existing.id
@pytest.mark.asyncio
async def test_favoriting_a_directory_result_keeps_it_after_clearing_the_filter(
monkeypatch,
) -> None:
app = BeefyPlayerApp(
provider="radiobrowser", autoplay=False, offline=True, audio=False
)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
app.offline = False
found = Station(
id="deep-jazz", title="Deep Jazz", provider="radiobrowser"
)
async def fake_search(self, client, query):
return [found]
monkeypatch.setattr(type(app.provider), "search_stations", fake_search)
await pilot.press("slash")
for char in "jazz":
await pilot.press(char)
await pilot.pause(0.4)
assert [station.id for station in app.view] == ["deep-jazz"]
await pilot.press("escape")
await pilot.pause()
app.action_favorite()
await pilot.pause()
assert found.favorite is True
assert "deep-jazz" in app.config.favorites
await pilot.press("escape")
await pilot.pause()
assert app.query == ""
assert app.view[0].id == "deep-jazz"
@pytest.mark.asyncio
async def test_missing_favorites_load_with_the_first_directory_page(
monkeypatch,
) -> None:
app = BeefyPlayerApp(
provider="radiobrowser", autoplay=False, offline=True, audio=False
)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
app.offline = False
favourite = Station(
id="12345678-1234-1234-1234-123456789abc",
title="Favourite",
provider="radiobrowser",
)
app.config.favorites = [favourite.id]
page = StationPage(
stations=[
Station(id="page-0", title="Page Zero", provider="radiobrowser")
],
next_offset=100,
)
async def fake_page(self, client, offset=0):
return page
async def fake_by_ids(self, client, station_ids):
assert station_ids == [favourite.id]
return [favourite]
monkeypatch.setattr(type(app.provider), "fetch_station_page", fake_page)
monkeypatch.setattr(
type(app.provider), "fetch_stations_by_ids", fake_by_ids
)
worker = app._refresh_stations()
await worker.wait()
await pilot.pause()
assert any(
station.id == favourite.id and station.favorite
for station in app.stations
)
assert app.view[0].id == favourite.id
@pytest.mark.asyncio
async def test_a_paginated_catalogue_loads_the_next_page_near_the_end(
monkeypatch,
) -> None:
app = BeefyPlayerApp(
provider="radiobrowser", autoplay=False, offline=True, audio=False
)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
app.offline = False
first = [
Station(
id=f"s{index}", title=f"Station {index}", provider="radiobrowser"
)
for index in range(100)
]
app._set_stations(first)
app._reset_directory_paging(first, has_more=True)
assert app._directory_offset == 100
assert app._directory_exhausted is False
app.index = 0
app._maybe_load_more_stations()
assert app._loading_more is False
seen: list[int] = []
async def fake_page(self, client, offset=0):
seen.append(offset)
return StationPage(
stations=[
Station(id="extra", title="Extra", provider="radiobrowser")
],
next_offset=None,
)
monkeypatch.setattr(type(app.provider), "fetch_station_page", fake_page)
app.index = 99
worker = app._load_more_stations()
await worker.wait()
await pilot.pause()
assert seen == [100]
assert any(station.id == "extra" for station in app.stations)
assert app._directory_exhausted is True
assert app.view[app.index].id == "s99"
@pytest.mark.asyncio
async def test_a_popularity_ordered_catalogue_keeps_its_order() -> None:
app = BeefyPlayerApp(
provider="radiobrowser", autoplay=False, offline=True, audio=False
)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
app._set_stations(
[
Station(id="zulu", title="Zulu", provider="radiobrowser"),
Station(id="alpha", title="Alpha", provider="radiobrowser"),
]
)
assert [station.title for station in app.view] == ["Zulu", "Alpha"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stage_renders_history_and_listener_count() -> None: async def test_stage_renders_history_and_listener_count() -> None:
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False) app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
+38 -20
View File
@@ -1,15 +1,17 @@
"""The BBS frames: grey edges, corners fading white → grey → cyan.""" """The BBS frames: grey edges, and a short palette glow in two corners."""
from __future__ import annotations from __future__ import annotations
from rich.text import Text from rich.text import Text
from beefy_player.palettes import PALETTES from beefy_player.palettes import PALETTES, brightness
from beefy_player.widgets import frame from beefy_player.widgets import frame
#: The original ladder, used as a fixed reference in these tests. #: The original ladder, used as a fixed reference in these tests.
STOPS = PALETTES["spectrum"] STOPS = PALETTES["spectrum"]
HOT = STOPS[-1] # the corner colour #: Its glow: the brightest cell first, dimmer cells towards the edge.
RAMP = frame.fade_ramp(STOPS)
HOT = RAMP[0] # the corner colour
def styles_of(line: Text) -> list[tuple[str, str]]: def styles_of(line: Text) -> list[tuple[str, str]]:
@@ -18,26 +20,29 @@ def styles_of(line: Text) -> list[tuple[str, str]]:
] ]
def test_the_corner_gets_the_brightest_colour_of_the_fade() -> None:
ranks = [brightness(colour) for colour in RAMP]
assert ranks == sorted(ranks, reverse=True)
# spectrum's hot end is red, which is dimmer than the yellow before it:
# the corner must still be the bright one, not the ladder's last stop
assert STOPS[-1] != HOT
assert brightness(HOT) > brightness(STOPS[-1])
assert set(RAMP) <= set(STOPS)
assert frame.corner_fade(len(RAMP), STOPS) == frame.EDGE
def test_only_the_top_right_corner_fades_on_the_top_edge() -> None: def test_only_the_top_right_corner_fades_on_the_top_edge() -> None:
spans = styles_of(frame.top(30, stops=STOPS)) spans = styles_of(frame.top(30, stops=STOPS))
assert spans[0] == ("╭", frame.EDGE) # plain grey corner assert spans[0] == ("╭", frame.EDGE) # plain grey corner
# the fade runs into the top-right corner, hot end last # the fade runs into the top-right corner, brightest cell last
assert [style for _text, style in spans[-4:-1]] == [ assert [style for _text, style in spans[-4:-1]] == [RAMP[3], RAMP[2], RAMP[1]]
STOPS[-4],
STOPS[-3],
STOPS[-2],
]
assert spans[-1] == ("╮", f"bold {HOT}") assert spans[-1] == ("╮", f"bold {HOT}")
def test_only_the_bottom_left_corner_fades_on_the_bottom_edge() -> None: def test_only_the_bottom_left_corner_fades_on_the_bottom_edge() -> None:
spans = styles_of(frame.bottom(30, STOPS)) spans = styles_of(frame.bottom(30, STOPS))
assert spans[0] == ("╰", f"bold {HOT}") assert spans[0] == ("╰", f"bold {HOT}")
assert [style for _text, style in spans[1:4]] == [ assert [style for _text, style in spans[1:4]] == [RAMP[1], RAMP[2], RAMP[3]]
STOPS[-2],
STOPS[-3],
STOPS[-4],
]
assert spans[-1] == ("╯", frame.EDGE) # plain grey corner assert spans[-1] == ("╯", frame.EDGE) # plain grey corner
@@ -51,15 +56,15 @@ def test_the_sides_glow_towards_their_own_corner_only() -> None:
frame.EDGE, frame.EDGE,
frame.EDGE, frame.EDGE,
frame.EDGE, frame.EDGE,
STOPS[-4], RAMP[3],
STOPS[-3], RAMP[2],
STOPS[-2], RAMP[1],
] ]
# right column fades down from the top-right corner # right column fades down from the top-right corner
assert [frame.side_style(row, 10, "right", STOPS) for row in range(10)] == [ assert [frame.side_style(row, 10, "right", STOPS) for row in range(10)] == [
STOPS[-2], RAMP[1],
STOPS[-3], RAMP[2],
STOPS[-4], RAMP[3],
frame.EDGE, frame.EDGE,
frame.EDGE, frame.EDGE,
frame.EDGE, frame.EDGE,
@@ -98,3 +103,16 @@ def test_content_wider_than_the_pane_is_trimmed_not_spilled() -> None:
assert lines[1].endswith("│") assert lines[1].endswith("│")
# the original renderable is left untouched # the original renderable is left untouched
assert long_line.plain == "x" * 40 assert long_line.plain == "x" * 40
def test_a_short_pane_still_glows_in_its_bottom_left_corner() -> None:
"""The fade follows the pane's height, not the number of content rows."""
group = frame.wrap([Text("one line")], 20, 6, stops=STOPS)
lines = list(group.renderables)
# rows 1-4 are the content box; the fade climbs its last three rows
assert str(lines[1].spans[0].style) == frame.EDGE
assert [str(line.spans[0].style) for line in lines[2:5]] == [
RAMP[3],
RAMP[2],
RAMP[1],
]
+21 -6
View File
@@ -13,6 +13,7 @@ from beefy_player.palettes import (
DEFAULT_PALETTE, DEFAULT_PALETTE,
PALETTE_ORDER, PALETTE_ORDER,
PALETTES, PALETTES,
brightness,
palette_stops, palette_stops,
) )
from beefy_player.widgets import frame from beefy_player.widgets import frame
@@ -133,13 +134,27 @@ async def test_the_spectrum_rows_use_the_active_palette() -> None:
assert colours(spectrum._row_palette(12, 0, False)) <= set(PALETTES["neon"]) assert colours(spectrum._row_palette(12, 0, False)) <= set(PALETTES["neon"])
def test_the_corner_fade_walks_back_down_the_ladder() -> None: def test_the_corner_fade_starts_at_the_brightest_stop() -> None:
stops = PALETTES["spectrum"] stops = PALETTES["spectrum"]
assert frame.corner_fade(0, stops) == stops[-1] ramp = frame.fade_ramp(stops)
assert frame.corner_fade(1, stops) == stops[-2] assert frame.corner_fade(0, stops) == ramp[0]
assert frame.corner_fade(3, stops) == stops[-4] assert frame.corner_fade(1, stops) == ramp[1]
# the fade is short: past four cells it is just the border grey # the fade is short: past the ramp it is just the border grey
assert frame.corner_fade(4, stops) == frame.EDGE assert frame.corner_fade(len(ramp), stops) == frame.EDGE
# spectrum's hot end is red, which is dimmer than the yellow before it;
# the corner still gets the bright colour
assert ramp[0] != stops[-1]
assert ramp[-1] == stops[-1]
def test_every_palette_fades_from_bright_to_dim() -> None:
"""No palette may put a brighter cell further from the corner."""
for name, stops in PALETTES.items():
ramp = frame.fade_ramp(stops)
assert ramp, name
ranks = [brightness(colour) for colour in ramp]
assert ranks == sorted(ranks, reverse=True), (name, ramp)
assert set(ramp) <= set(stops), name
@pytest.mark.asyncio @pytest.mark.asyncio
+115 -4
View File
@@ -66,25 +66,136 @@ def test_hls_streams_survive_as_direct_urls() -> None:
assert station.format_label == "aac · 64k" assert station.format_label == "aac · 64k"
@pytest.mark.asyncio
async def test_search_stations_queries_all_searchable_fields() -> None:
entries = {
"name": {**ENTRY, "stationuuid": "name", "name": "Jazz Name"},
"tag": {**ENTRY, "stationuuid": "tag", "name": "Zed Jazz Tag"},
"country": {**ENTRY, "stationuuid": "country", "name": "Country Jazz"},
"language": {**ENTRY, "stationuuid": "language", "name": "Language Jazz"},
}
seen: list[dict[str, str]] = []
def handler(request: httpx.Request) -> httpx.Response:
params = dict(request.url.params)
seen.append(params)
field = next(name for name in api._SEARCH_FIELDS if name in params)
payload = [entries[field]]
if field in ("name", "tag"):
payload.append(ENTRY) # the same station may match twice
return httpx.Response(200, json=payload)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
stations = await api.search_stations(client, "jazz")
assert {station.id for station in stations} == {
ENTRY["stationuuid"],
"name",
"tag",
"country",
"language",
}
assert [params["limit"] for params in seen] == [
str(api.SEARCH_LIMIT)
] * len(api._SEARCH_FIELDS)
for params in seen:
assert params["hidebroken"] == "true"
assert params["order"] == "clickcount"
assert params["reverse"] == "true"
assert {next(name for name in api._SEARCH_FIELDS if name in params) for params in seen} == set(
api._SEARCH_FIELDS
)
@pytest.mark.asyncio
async def test_search_stations_ignores_a_failed_field(monkeypatch) -> None:
async def fake_fetch_json(client, url):
if "name=jazz" in url:
raise RuntimeError("name search is down")
return [ENTRY]
monkeypatch.setattr(api, "fetch_json", fake_fetch_json)
async with httpx.AsyncClient() as client:
stations = await api.search_stations(client, "jazz")
assert [station.id for station in stations] == [ENTRY["stationuuid"]]
@pytest.mark.asyncio
async def test_an_empty_search_does_not_hit_the_api() -> None:
def handler(request: httpx.Request) -> httpx.Response:
raise AssertionError("empty queries must not make a request")
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
assert await api.search_stations(client, " ") == []
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fetch_stations_caches_the_directory() -> None: async def test_fetch_stations_caches_the_directory() -> None:
payload = [ENTRY, {**ENTRY, "stationuuid": "x", "name": "Another"}] payload = [ENTRY, {**ENTRY, "stationuuid": "x", "name": "Another"}]
def handler(request: httpx.Request) -> httpx.Response: def handler(request: httpx.Request) -> httpx.Response:
assert "hidebroken=true" in str(request.url) params = dict(request.url.params)
assert "limit=300" in str(request.url) assert params["hidebroken"] == "true"
assert params["limit"] == str(api.PAGE_SIZE)
assert params["offset"] == "0"
return httpx.Response(200, json=payload) return httpx.Response(200, json=payload)
provider = RadioBrowserProvider() provider = RadioBrowserProvider()
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
stations = await provider.fetch_stations(client) stations = await provider.fetch_stations(client)
assert [station.title for station in stations] == ["Another", "France Info"] assert [station.title for station in stations] == ["France Info", "Another"]
assert [station.title for station in provider.load_cached_stations()] == [ assert [station.title for station in provider.load_cached_stations()] == [
"Another",
"France Info", "France Info",
"Another",
] ]
@pytest.mark.asyncio
async def test_fetch_station_page_continues_in_catalogue_order() -> None:
first = [
{**ENTRY, "stationuuid": f"id-{index}", "name": f"Station {index}"}
for index in range(api.PAGE_SIZE)
]
last = [{**ENTRY, "stationuuid": "last", "name": "Last Station"}]
seen: list[dict[str, str]] = []
def handler(request: httpx.Request) -> httpx.Response:
params = dict(request.url.params)
seen.append(params)
return httpx.Response(200, json=first if params["offset"] == "100" else last)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
page = await api.fetch_station_page(client, 100)
end = await api.fetch_station_page(client, 200)
assert [station.id for station in page.stations] == [
f"id-{index}" for index in range(api.PAGE_SIZE)
]
assert page.next_offset == 200
assert end.next_offset is None
assert [params["offset"] for params in seen] == ["100", "200"]
assert [params["limit"] for params in seen] == [str(api.PAGE_SIZE)] * 2
@pytest.mark.asyncio
async def test_fetch_stations_by_ids_uses_the_uuid_endpoint() -> None:
seen: list[dict[str, str]] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(dict(request.url.params))
return httpx.Response(200, json=[ENTRY])
station_id = ENTRY["stationuuid"]
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
stations = await api.fetch_stations_by_ids(
client, ["defcon", station_id, station_id]
)
assert [station.id for station in stations] == [station_id]
assert len(seen) == 1
assert seen[0]["uuids"] == station_id
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_a_failed_fetch_does_not_kill_the_catalogue() -> None: async def test_a_failed_fetch_does_not_kill_the_catalogue() -> None:
def handler(request: httpx.Request) -> httpx.Response: def handler(request: httpx.Request) -> httpx.Response:
Generated
+1 -1
View File
@@ -32,7 +32,7 @@ wheels = [
[[package]] [[package]]
name = "beefy-player" name = "beefy-player"
version = "1.1.0" version = "1.2.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "dbus-next", marker = "sys_platform == 'linux'" }, { name = "dbus-next", marker = "sys_platform == 'linux'" },