Compare commits

...
15 Commits
Author SHA1 Message Date
beefcarver e6684ddbbd docs(readme): document makepkg install while AUR sign-ups are closed 2026-11-04 17:07:14 -05:00
beefcarver d06a94f388 build(aur): add generated .SRCINFO metadata 2026-11-04 17:05:04 -05:00
beefcarver 1f686ce7ba build(homebrew): add Homebrew tap formula and publishing notes 2026-11-04 17:04:52 -05:00
beefcarver cbcdfae8bf build(aur): add AUR packaging 2026-11-04 17:04:47 -05:00
beefcarver ffd67fc90d docs(readme): document Arch Linux AUR installation 2026-11-04 17:04:38 -05:00
beefcarver 941c744638 docs(readme): trim licence section to the licence reference 2026-11-04 16:22:54 -05:00
beefcarver 2753a7b6ba chore(release): bump version to 1.2.2 2026-11-04 16:20:38 -05:00
beefcarver dc6ed00abe docs(readme): document Nix installation, modules and the flake dev shell 2026-11-04 16:20:38 -05:00
beefcarver 12792b1265 build(nix): add flake with package, dev shell and NixOS/Home Manager modules 2026-11-04 16:20:38 -05:00
beefcarver 4346f75eac test: spawn fake mpv and parec helpers with sys.executable 2026-11-04 16:20:38 -05:00
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
25 changed files with 1910 additions and 624 deletions

No files matched your search

+212 -531
View File
@@ -3,370 +3,208 @@
![beefy-player demo](assets/beefy-player-demo.gif)
A flashy terminal player for **internet radio** — a live spectrum analyser,
song history, and full desktop media integration, drawn with your terminal's
own ANSI palette on the terminal's default background so it fits any theme
(including transparent terminals).
song history and album art, with desktop media integration on Linux and macOS.
It draws with your terminal's own ANSI palette on the terminal's default
background, so it fits any theme, including transparent terminals.
The station catalogue is pluggable. Beefy Player ships with thirteen catalogues
— **[SomaFM](https://somafm.com)** (≈50 curated channels),
**[Radio Paradise](https://radioparadise.com)** (five mixes), the community
stations **[tilderadio](https://tilderadio.org)**,
**[Bassdrive](https://bassdrive.com)**, **[dublab](https://dublab.com)**,
**[Rinse FM](https://rinse.fm)**, **[Refuge Worldwide](https://refugeworldwide.com)**,
**[LYL Radio](https://lyl.live)**, **[The Lot Radio](https://thelotradio.com)**,
**[Cashmere Radio](https://cashmereradio.com)**,
**[Datafruits](https://datafruits.fm)** and **[aNONradio](https://anonradio.net)**,
plus the **[Radio Browser](https://www.radio-browser.info)** directory
(300 stations) — and the UI never talks to a station catalogue directly: it asks a
[provider](#providers) for stations, song history and stream URLs. Press `p` to
switch catalogue without restarting. Adding another internet-radio catalogue is
a self-contained module, not a rewrite.
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.
Thirteen station catalogues ship today, from SomaFM's curated channels to the
62,000-station Radio Browser directory, loaded as you scroll and searched live
as you type. Press `p` to switch catalogue without restarting.
## Features
- **Live spectrum analyser** — 72 log-spaced bands with peak caps, a moving
gradient taken from your terminal's ANSI palette and a symmetric mirrored
spectrum. Six modes (`v`): mirrored bars, plain bars, oscilloscope,
scrolling waterfall spectrogram, a radial spectrum wrapped around the
centre, and a high-resolution braille oscilloscope (2×4 dots per cell).
- **Synced spectrum, not a guess** — by default the analyser records your
actual audio output, so the bars follow what you hear (`SYNC`): the
PulseAudio/PipeWire monitor via `parec` on Linux, and a native Core Audio
process tap on macOS (no driver, no BlackHole, nothing to install). Without
an output capture it falls back to a muted second `ffmpeg` decode of the
stream (`FFT`, can drift), then to a synthesized display (`SIM`).
- **Real artwork on iTerm2, Kitty and WezTerm** — Kitty uses the kitty
graphics protocol; iTerm2 (including on macOS) and WezTerm use their native
iTerm2 inline-image protocol. Images occupy exactly the ON AIR art cell box
and fall back to the procedural ANSI mosaic for other terminals, failed
loads, or blocked graphics passthrough. Images are cached in
`~/.cache/beefy-player/artwork`, letterboxed rather than cropped, and an
image that carries an alpha channel is composited onto black — a transparent
PNG drawn over the mosaic reads as noise behind a station logo.
- **Now playing** — artist, title, album, DJ, listeners, format/bitrate and
"played N minutes ago", with procedural ANSI-shaded album art that changes
per track. When the current track can be found on Bandcamp, a clickable
**Bandcamp ↗** link appears below it; otherwise the line stays hidden.
Catalogues can opt out with `bandcamp = False` (tilderadio does), and a
provider can fill the `Station.presenter` line instead — tilderadio shows
`auto dj · library rotation` between live shows and `dj <name>` during one.
- **Last played** — the full recent history, newest first.
- **Every station** — with SomaFM that is the whole `channels.json` roster
(≈50 channels), live listener counts and genre search; tilderadio is a
single-station catalogue, and the list simply holds one. Long lists get a grey
thumb down the right edge (no rail) and a `12/51` position in the pane title;
`pgup`/`pgdn` move a screen at a time.
- **Favourites** that float to the top, remembered between runs.
- **Pluggable catalogues** — one `providers` module per radio network, with a
`p` picker in the UI, `--provider` on the command line, and the choice
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.
- **Live spectrum analyser** — 72 log-spaced bands with peak caps, a palette
gradient and a mirrored spectrum. Six modes (`v`): mirrored bars, plain bars,
oscilloscope, scrolling waterfall, radial spectrum and a high-resolution
braille oscilloscope.
- **Synced to what you hear** — the analyser listens to your actual audio
output by default, so the bars follow the music. If that is unavailable it
falls back to decoding the stream, then to a simulated display.
- **Now playing and history** — artist, title, album, DJ, listeners, bitrate
and "played N minutes ago", plus the full recent history. A clickable
**Bandcamp ↗** link appears when the current track can be found there.
- **Real album art** — native images in Kitty, iTerm2 and WezTerm; an
ANSI-shaded mosaic in every other terminal.
- **Thirteen station catalogues** — SomaFM (≈50 channels), Radio Paradise,
tilderadio, Bassdrive, dublab, Rinse FM, Refuge Worldwide, LYL Radio, The Lot
Radio, Cashmere Radio, Datafruits, aNONradio and the Radio Browser directory
(loaded as you scroll in popularity order, with live search across all
62,000).
- **Favourites, search and schedules** — favourites float to the top and are
remembered between runs, including directory stations outside the loaded
page; search by title, genre or description, with live server-side search
across the Radio Browser directory; single-stream stations show their
broadcast schedule instead of a one-item list.
- **Desktop media integration** — MPRIS on Linux (GNOME/KDE widgets, lock
screens, `playerctl` and the media keys) and Now Playing on macOS (Control
Center and the media keys).
- **Sixteen palettes, theme-aware** — every colour is native ANSI against the
terminal's default background, so transparent terminals stay transparent and
the TUI follows your colour scheme. Press `c` to cycle palettes.
- **Graceful offline** — cached stations and schedules, a bundled fallback
list, automatic mirror failover and `ffplay` when `mpv` is missing.
## Requirements
## Installation
`beefy-player` is a normal Python application: there is nothing to compile and
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`.
### Nix
| 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):
The flake builds the package, runs the test suite and wraps everything the app
calls at runtime (`mpv`, `ffmpeg`, and PulseAudio's `parec`/`pactl` — PipeWire
speaks the same protocol):
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
nix run git+https://tildeforge.dev/beefcarver/beefy-player # try it
nix profile install git+https://tildeforge.dev/beefcarver/beefy-player # install it
```
`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`.
In a NixOS configuration:
### Linux
```nix
{
inputs.beefy-player.url = "git+https://tildeforge.dev/beefcarver/beefy-player";
Install these with your package manager. Names are usually:
outputs = { nixpkgs, beefy-player, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
modules = [
beefy-player.nixosModules.default
{
programs.beefy-player.enable = true;
}
];
};
};
}
```
Or, for a single user with Home Manager:
```nix
{
inputs.beefy-player.url = "git+https://tildeforge.dev/beefcarver/beefy-player";
outputs = { home-manager, beefy-player, ... }: {
homeConfigurations.me = home-manager.lib.homeManagerConfiguration {
modules = [
beefy-player.homeModules.default
{
programs.beefy-player.enable = true;
}
];
};
};
}
```
Both modules add one option, `programs.beefy-player.package`, so a pinned or
overridden build can be substituted for the flake's own. The raw package
(`packages.${system}.default`) and an overlay (`overlays.default`) are exported
too. From a checkout, `nix develop` gives you `uv`, `ruff` and the audio tools
for the usual `uv sync` / `uv run pytest` workflow; `nix build` builds and
tests the package.
### Arch Linux
The `aur/PKGBUILD` here is AUR-ready, but the package is not published in the
AUR: the AUR has closed new sign-ups, so there is no account to push it to.
Build and install it from a checkout instead:
```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
git clone https://tildeforge.dev/beefcarver/beefy-player
cd beefy-player/aur
makepkg -si # needs base-devel; builds, runs the test suite, installs
```
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`.
Once the AUR accepts new accounts again, the package can be published and
`paru -S beefy-player` / `yay -S beefy-player` will work; until then `makepkg`
is the only route. The `aur/PKGBUILD` is tracked and kept in sync with every
release either way.
### macOS
The Python side is pulled in automatically. For audio, the output-synced
spectrum and image passthrough through tmux, install the optional tools too:
```bash
brew install mpv ffmpeg uv # ffplay ships with ffmpeg
sudo pacman -S mpv ffmpeg libpulse tmux
```
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:
### From source
```bash
uv sync # create .venv and install exactly what uv.lock pins
uv run beefy-player # run inside that environment
uv sync
uv run beefy-player
```
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`:
## Usage
```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
beefy-player
```
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.
## 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 |
| key | action |
| -------------- | --------------------------------------------- |
| `↑` / `↓`, `j` / `k` | move through stations |
| `pgup` / `pgdn` | page through stations |
| `enter` | tune in to the selected station |
| `space` | play / pause |
| `s` | stop |
| `]` / `[` | next / previous station (tunes immediately) |
| `+` / `-` | volume |
| `m` | mute |
| `f` | favourite (favourites sort to the top) |
| `/` | filter by title, genre, description |
| `esc` | clear filter / close search |
| `v` | visualiser: mirror → bars → wave → waterfall → radial → braille |
| `c` | colour palette (16 of them, remembered) |
| `r` | refresh stations, schedule + song history |
| `p` | switch station catalogue (SomaFM / tilderadio)|
| `g` / `G` | top / bottom |
| `?` | help |
| `q` | quit |
Environment variables are prefixed `BEEFY_`: `BEEFY_IMAGES` (`tgp`, `iterm2`
or `0`), `BEEFY_MPV_ARGS` and `BEEFY_SPECTRUM_DEVICE`.
## Desktop media widgets (MPRIS / Now Playing)
### Keys
**Linux** — while playing, the app exports
`org.mpris.MediaPlayer2.beefyplayer.instance<pid>` on the **session bus**. Check
it:
| key | action |
| --- | --- |
| `↑` / `↓`, `j` / `k` | move through stations |
| `pgup` / `pgdn` | page through stations |
| `enter` | tune in to the selected station |
| `space` | play / pause |
| `s` | stop |
| `]` / `[` | next / previous station (tunes immediately) |
| `+` / `-` | volume |
| `m` | mute |
| `f` | favourite (favourites sort to the top) |
| `/` | filter by title, genre, description |
| `esc` | close the search box; press again to clear the filter |
| `v` | visualiser: mirror → bars → wave → waterfall → radial → braille |
| `c` | colour palette (16 of them, remembered) |
| `r` | refresh stations, schedule and song history |
| `p` | switch station catalogue |
| `g` / `G` | top / bottom |
| `?` | help |
| `q` | quit |
## Station catalogues
| catalogue | slug | stations |
| --- | --- | --- |
| [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
playerctl -l
@@ -374,221 +212,64 @@ playerctl metadata --format '{{artist}} — {{title}} ({{album}})'
playerctl play-pause
```
`mpris:artUrl` is the track's album art when the provider supplies it,
otherwise the channel logo. If there is no session bus (SSH without
`dbus-run-session`, containers, …) MPRIS is skipped and the status bar shows
`mpris off`.
If there is no session bus (SSH without `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
menu-bar widget and the keyboard media keys (▶︎/⏸, ⏭, ⏮) all target this app
while it is playing. mpv publishes the live stream title (the same
“Artist - Title” that the station sends with the audio), and the media keys are
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** — mpv publishes to the system Now Playing session, so Control Center,
the menu bar and the media keys drive the app while it plays. The status bar
shows `nowplaying on`. `--no-mpris` (also spelled `--no-media`) turns the media
keys off.
> macOS shows one Now Playing app at a time. If Music/Safari/another mpv is
> playing, the media keys go there instead — quit it or pause it to hand the
> keys back.
> macOS shows one Now Playing app at a time. If Music, Safari or another mpv is
> playing, the media keys go there instead.
## How it works
## Configuration
```
beefy_player/
app.py Textual app: layout, keys, orchestration
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, …)
Settings live in `~/.config/beefy-player/config.json` (favourites, volume,
provider and the last station tuned in each catalogue). Cached stations,
schedules and artwork live in `~/.cache/beefy-player/`.
tools/
ansi_art.py logo PNG -> half-block ANSI hero (regenerates tilderadio's)
```
These stay in the usual XDG locations on every platform, macOS included;
`XDG_CONFIG_HOME` and `XDG_CACHE_HOME` are honoured when set.
- `beefy_player/providers/base.py` — the provider interface and the shared
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.
## Troubleshooting
### Files
```
~/.config/beefy-player/config.json favourites, volume, provider, last stations
~/.cache/beefy-player/channels.json last good SomaFM station list
~/.cache/beefy-player/channels-<id>.json last good list, other catalogues
~/.cache/beefy-player/schedule-<id>.json last good broadcast schedule
~/.cache/beefy-player/playlists.json resolved stream URLs
~/.cache/beefy-player/songs/<id>.json SomaFM song history snapshots
~/.cache/beefy-player/songs/<p>-<id>.json history for every other catalogue
~/.cache/beefy-player/artwork/<sha1> cached album/channel artwork
```
`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`.
- **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.
`--diagnose-images` prints what was detected.
- **No audio?** `mpv` is preferred, `ffplay` works as a fallback. Without
either, the UI still runs and tells you. Extra mpv arguments can be injected
with `BEEFY_MPV_ARGS`, e.g. `BEEFY_MPV_ARGS='--audio-device=...'`.
- **Spectrum says `FFT` instead of `SYNC`?** Output capture is unavailable or
failed, so the analyser is decoding the stream instead; the bars are still
real audio but can drift. Force sync with `--spectrum-source monitor`, or
pick a specific monitor with `BEEFY_SPECTRUM_DEVICE` on Linux. `SIM` means the
bars are synthesized.
- **macOS: spectrum stays `FFT`?** Grant your terminal **System Audio
Recording** permission (*System Settings → Privacy & Security → System Audio
Recording*), then restart the app; the status bar explains when the tap is
silent because of this. Also check that mpv is playing to the default output
device, since that is what the tap records.
- **macOS: media keys do nothing?** Another app currently owns the Now Playing
session, or the app was started with `--no-mpris`, or the audio backend fell
back to `ffplay` (mpv is required for Now Playing). The status bar shows
`nowplaying on` when the integration is armed.
- **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.
Recording** permission under *System Settings → Privacy & Security → System
Audio Recording*, then restart the app. Also check that mpv is playing to the
default output device, since that is what the analyser records.
- **macOS: media keys do nothing?** Another app owns the Now Playing session,
the app was started with `--no-mpris`, or audio fell back to `ffplay` (mpv is
required for Now Playing).
- **MPRIS says off?** There is no session bus. Run from your desktop session,
or use `dbus-run-session` for a private bus. macOS does not use MPRIS.
## NixOS (optional aside)
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:
## Development
```bash
nix-shell # enter the FHS dev shell
uv sync
uv run beefy-player
uv sync # create the environment
uv run pytest # test suite
uvx ruff check . # lint
```
- If you see `libstdc++.so.6: cannot open shared object file`, make sure you
are inside `nix-shell`, then recreate the venv once with
`rm -rf .venv && uv sync`.
- `nix-shell --run "..."` does not work with FHS `shell.nix` files; enter the
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.
Each catalogue is a self-contained provider module under
`beefy_player/providers/`; `base.py` documents the interface. `tools/ansi_art.py`
converts a PNG logo into half-block ANSI art. On NixOS, `nix develop` (or the
older `shell.nix`) provides the same `uv sync` / `uv run` workflow.
## Licence
WTFPL — Do What The Fuck You Want To Public License, Version 2. See
[LICENSE](LICENSE). SomaFM and its channel names are trademarks of
SomaFM.com, LLC. tilderadio belongs to the tildeverse community.
Terminal image rendering uses [Pillow](https://python-pillow.org/).
WTFPL — Do What The Fuck You Want To Public License, Version 2.
See [LICENSE](LICENSE).
+28
View File
@@ -0,0 +1,28 @@
pkgbase = beefy-player
pkgdesc = Flashy terminal player for internet radio with a live spectrum analyser
pkgver = 1.2.2
pkgrel = 1
url = https://tildeforge.dev/beefcarver/beefy-player
arch = any
license = WTFPL
depends = python
depends = python-dbus-next
depends = python-httpx
depends = python-numpy
depends = python-pillow
depends = python-rich
depends = python-textual
makedepends = python-build
makedepends = python-hatchling
makedepends = python-installer
makedepends = python-wheel
checkdepends = python-pytest
checkdepends = python-pytest-asyncio
optdepends = ffmpeg: decode the stream for the spectrum analyser, and ffplay as the audio fallback
optdepends = libpulse: capture the output monitor so the analyser follows what you hear (parec/pactl)
optdepends = mpv: preferred audio backend
optdepends = tmux: detect the outer terminal for kitty graphics passthrough inside tmux
source = beefy-player-1.2.2.tar.gz::https://tildeforge.dev/beefcarver/beefy-player/archive/941c7446384bf346e40d1438a7ce67939d9615df.tar.gz
sha256sums = 8ac568676ef5ef8345441c28e4f8f5d61fb06d5225e566ac578acae18672c778
pkgname = beefy-player
+59
View File
@@ -0,0 +1,59 @@
# Maintainer: beefy-player contributors <[email protected]>
pkgname=beefy-player
pkgver=1.2.2
pkgrel=1
pkgdesc="Flashy terminal player for internet radio with a live spectrum analyser"
arch=('any')
url="https://tildeforge.dev/beefcarver/beefy-player"
license=('WTFPL')
depends=(
'python'
'python-dbus-next'
'python-httpx'
'python-numpy'
'python-pillow'
'python-rich'
'python-textual'
)
makedepends=(
'python-build'
'python-hatchling'
'python-installer'
'python-wheel'
)
checkdepends=(
'python-pytest'
'python-pytest-asyncio'
)
optdepends=(
'ffmpeg: decode the stream for the spectrum analyser, and ffplay as the audio fallback'
'libpulse: capture the output monitor so the analyser follows what you hear (parec/pactl)'
'mpv: preferred audio backend'
'tmux: detect the outer terminal for kitty graphics passthrough inside tmux'
)
# Upstream does not tag releases yet, so the source is the archive of the
# commit that carries this version. Once a `v$pkgver` tag exists, switch to
#
# source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
#
_commit=941c7446384bf346e40d1438a7ce67939d9615df
source=("$pkgname-$pkgver.tar.gz::$url/archive/$_commit.tar.gz")
sha256sums=('8ac568676ef5ef8345441c28e4f8f5d61fb06d5225e566ac578acae18672c778')
build() {
cd "$pkgname"
python -m build --wheel --no-isolation
}
check() {
cd "$pkgname"
# The suite is offline: catalogues are fixtures, mpv/parec are faked.
python -m pytest -q
}
package() {
cd "$pkgname"
python -m installer --destdir="$pkgdir" dist/*.whl
}
+81
View File
@@ -0,0 +1,81 @@
# AUR packaging for beefy-player
An AUR repository holds exactly two tracked files, `PKGBUILD` and `.SRCINFO`.
This directory is the staging area for both; the rest of the project is
irrelevant to the AUR.
## Build it locally
```bash
cd aur
makepkg -si # build, run the test suite, install
```
`makepkg -o && makepkg -e` skips the download/checksum phase, and
`extra-x86_64-build` (devtools) builds in a clean chroot. The package builds
with `base-devel` plus the `makedepends`/`checkdepends` listed in the
`PKGBUILD`; every dependency comes from the official repositories, none from
the AUR.
## Publish to the AUR
```bash
git clone ssh://[email protected]/beefy-player.git /tmp/aur-beefy-player
cp PKGBUILD .SRCINFO /tmp/aur-beefy-player/
cd /tmp/aur-beefy-player
git add PKGBUILD .SRCINFO
git commit -m "Initial import: beefy-player 1.2.2-1"
git push
```
## Releasing a new version
1. Set `pkgver` in `PKGBUILD` and `pyproject.toml`, and reset `pkgrel=1`.
2. Point `_commit` at the commit carrying that version:
```bash
git rev-parse HEAD # or the tag, once one exists
```
3. Refresh the checksum:
```bash
curl -fsSL "https://tildeforge.dev/beefcarver/beefy-player/archive/$_commit.tar.gz" | sha256sum
```
4. Regenerate the metadata (on Arch, or inside a chroot):
```bash
makepkg --printsrcinfo > .SRCINFO
```
5. Push to the AUR with the conventional commit message:
```bash
git commit -am "upgpkg: beefy-player <pkgver>-<pkgrel>"
```
## Why the source is a commit archive
Upstream publishes no tags or releases, so the checksum in `sha256sums`
belongs to the archive of a specific commit rather than of a `v$pkgver` tag.
Forge archives of a commit are deterministic, so the checksum stays stable.
Once `v$pkgver` is tagged, replace the `source` line with
```bash
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
```
## Packaging notes
- The wheel is built with `hatchling`; `theme.tcss` is force-included by
`pyproject.toml`, so it ends up in `site-packages/beefy_player/`.
- `check()` runs the real test suite. It is offline by design (station
catalogues are fixtures and `mpv`/`parec` are faked), which makes it safe in
a build chroot with no network.
- `mpv`, `ffmpeg`, `libpulse` (`parec`/`pactl`) and `tmux` are `optdepends`,
not `depends`: the player degrades gracefully without them and says so in
the UI. Users who want sound need `mpv` (or `ffmpeg` for `ffplay`) and, for
the output-synced spectrum, `libpulse`.
- `license=('WTFPL')` is an SPDX identifier shipped by Arch's `licenses`
package.
+1 -1
View File
@@ -1,3 +1,3 @@
"""Beefy Player — a flashy terminal player for internet radio."""
__version__ = "1.1.0"
__version__ = "1.2.2"
+354 -11
View File
@@ -44,6 +44,10 @@ from .widgets.status import StatusBar
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
# 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.
@@ -146,6 +150,20 @@ class BeefyPlayerApp(App[None]):
self.schedule_loaded = False
self.index = 0
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.playing_state = "stopped"
self.volume = self.config.volume
@@ -209,9 +227,11 @@ class BeefyPlayerApp(App[None]):
self._apply_catalogue_chrome()
stations = self.provider.load_cached_stations()
cached = bool(stations)
if not stations:
stations = self.provider.fallback_stations()
self._set_stations(stations)
self._reset_directory_paging(stations, has_more=cached)
self._select_initial_station()
if self.current is not None:
@@ -259,10 +279,35 @@ class BeefyPlayerApp(App[None]):
self.config.visualizer = self.visualizer
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:
if not stations:
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}
if self.current is not None:
existing[self.current.id] = self.current
for station in stations:
previous = existing.get(station.id)
if previous is not None:
@@ -284,6 +329,33 @@ class BeefyPlayerApp(App[None]):
self._update_artwork()
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:
if not self.view:
return
@@ -307,7 +379,8 @@ class BeefyPlayerApp(App[None]):
break
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:
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):
keep_id = self.view[self.index].id
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 (
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)]
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
if keep_id is not None:
@@ -338,6 +430,39 @@ class BeefyPlayerApp(App[None]):
if self.current is None and view:
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
# ------------------------------------------------------------------ #
@@ -362,6 +487,9 @@ class BeefyPlayerApp(App[None]):
def _reset_filter(self) -> None:
"""Forget the station filter and put the search box away."""
self.query = ""
self.search_results = None
self._search_results_query = ""
self.search_pending = False
search = self.query_one(SearchInput)
search.value = ""
search.remove_class("visible")
@@ -385,6 +513,12 @@ class BeefyPlayerApp(App[None]):
def _tune(self, station: Station) -> None:
self.current = station
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._set_notice(f"tuning in to {station.title}…", 5)
self._save_config()
@@ -405,7 +539,7 @@ class BeefyPlayerApp(App[None]):
@work(exclusive=True, group="stream", exit_on_error=False)
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:
return
@@ -440,7 +574,7 @@ class BeefyPlayerApp(App[None]):
@work(exclusive=True, group="history", exit_on_error=False)
async def _fetch_history(self, station_id: str) -> None:
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:
return
try:
@@ -559,7 +693,7 @@ class BeefyPlayerApp(App[None]):
return
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:
return
song = station.now_playing
@@ -793,27 +927,179 @@ class BeefyPlayerApp(App[None]):
# 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)
async def _refresh_stations(self) -> None:
if self.offline or self._http is None:
return
try:
stations = await self.provider.fetch_stations(self._http)
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)
next_offset = None
if not stations:
raise RuntimeError("the catalogue returned no stations")
except Exception:
if not self.stations:
self._set_stations(self.provider.fallback_stations())
self._set_notice("offline — using the built-in station list", 8)
else:
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
before = self.current.id if self.current is not None else None
self._set_stations(stations)
if before is not None:
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(
"1 station" if len(stations) == 1 else f"{len(stations)} stations", 3
)
self.query_one(StationList).refresh()
await self._load_favorite_stations()
# ------------------------------------------------------------------ #
# actions
@@ -830,6 +1116,7 @@ class BeefyPlayerApp(App[None]):
return
self.index = min(len(self.view) - 1, self.index + 1)
self.query_one(StationList).refresh()
self._maybe_load_more_stations()
def _page(self, delta: int) -> None:
if not self.view:
@@ -837,6 +1124,7 @@ class BeefyPlayerApp(App[None]):
page = max(1, self.query_one(StationList).size.height - 1)
self.index = max(0, min(len(self.view) - 1, self.index + delta * page))
self.query_one(StationList).refresh()
self._maybe_load_more_stations()
def action_page_up(self) -> None:
self._page(-1)
@@ -855,6 +1143,7 @@ class BeefyPlayerApp(App[None]):
return
self.index = len(self.view) - 1
self.query_one(StationList).refresh()
self._maybe_load_more_stations()
def action_tune(self) -> None:
if not self.view:
@@ -914,6 +1203,21 @@ class BeefyPlayerApp(App[None]):
return
added = self.config.toggle_favorite(station.id)
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._rebuild_view(keep_id=station.id)
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)
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)
else:
# Put the search box away but keep the filter on the station list.
self._close_search(clear=False)
def _close_search(self, clear: bool = False) -> None:
@@ -942,6 +1249,9 @@ class BeefyPlayerApp(App[None]):
search.remove_class("visible")
if clear:
self.query = ""
self.search_results = None
self._search_results_query = ""
self.search_pending = False
search.value = ""
search.disabled = True
self.set_focus(None)
@@ -949,6 +1259,7 @@ class BeefyPlayerApp(App[None]):
self.query_one(StationList).set_filter(self.query)
self.query_one(StationList).refresh()
self.query_one(StagePanel).refresh_stage()
self._maybe_load_more_stations()
def action_visualizer(self) -> None:
modes = list(config.VISUALIZERS)
@@ -979,6 +1290,9 @@ class BeefyPlayerApp(App[None]):
self._last_history_fetch = 0.0
self._bandcamp_key = ""
self._bandcamp_urls.clear()
with contextlib.suppress(Exception):
self.workers.cancel_group(self, "stations-page")
self._loading_more = False
self._set_notice("refreshing…", 2)
if self.offline:
self._set_notice("offline mode — nothing to refresh", 3)
@@ -1009,7 +1323,16 @@ class BeefyPlayerApp(App[None]):
self.query_one(ArtPane).resume()
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):
self.workers.cancel_group(self, group)
@@ -1039,6 +1362,10 @@ class BeefyPlayerApp(App[None]):
self.stations = []
self.view = []
self.index = 0
self.search_results = None
self._search_results_query = ""
self.search_pending = False
self._discovered_station_ids.clear()
self.current = None
self.schedule = []
self.schedule_loaded = False
@@ -1055,8 +1382,12 @@ class BeefyPlayerApp(App[None]):
self._apply_catalogue_chrome()
with contextlib.suppress(NoMatches):
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._reset_directory_paging(stations, has_more=cached)
self._select_initial_station()
self._update_media()
self._update_artwork()
@@ -1096,15 +1427,27 @@ class BeefyPlayerApp(App[None]):
if event.input.id != "search":
return
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.query_one(StationList).set_filter(self.query)
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:
if event.input.id != "search":
return
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:
self._tune(station)
+31
View File
@@ -75,6 +75,32 @@ PALETTES: dict[str, tuple[str, ...]] = {
"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.
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."""
key = (name or "").strip().lower()
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 time
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
from urllib.parse import quote
@@ -29,6 +30,7 @@ from ..models import Broadcast, Song, Station
__all__ = [
"PLAYLIST_TTL",
"RadioProvider",
"StationPage",
"fetch_json",
"is_direct_stream",
"is_playlist",
@@ -62,6 +64,15 @@ def reset_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
# --------------------------------------------------------------------------- #
@@ -114,6 +125,20 @@ class RadioProvider(ABC):
#: stations that would rather not be scraped).
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 ------------------------------------------------------- #
@abstractmethod
@@ -128,6 +153,37 @@ class RadioProvider(ABC):
def fallback_stations(self) -> list[Station]:
"""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 -------------------------------------------------------- #
@abstractmethod
@@ -1,8 +1,9 @@
"""Radio Browser: a directory of the community's stations rather than a station.
Three hundred of the most-clicked working relays, keyless and public-domain.
There is no now-playing API, so this catalogue offers breadth, not history: pick
a station and the stream speaks for itself.
The most-clicked working relays are loaded a page at a time, and the rest of
the 60,000-station directory is searched on the server as you type. There is no
now-playing API, so this catalogue offers breadth, not history: pick a station
and the stream speaks for itself.
"""
from __future__ import annotations
@@ -11,7 +12,7 @@ import httpx
from ... import config
from ...models import Song, Station
from ..base import RadioProvider
from ..base import RadioProvider, StationPage
from . import api, fallback
from . import hero as hero_art
@@ -23,8 +24,8 @@ class RadioBrowserProvider(RadioProvider):
name = "Radio Browser"
homepage = "radio-browser.info"
brand = "RADIO BROWSER"
tagline = "community directory · 50,000 stations"
station_source = "radio-browser.info (top 300)"
tagline = "community directory · live search"
station_source = "radio-browser.info (paged + search)"
history_source = ""
hero = hero_art.HERO
@@ -33,6 +34,14 @@ class RadioBrowserProvider(RadioProvider):
# A directory has no show schedule and no Bandcamp worth scraping.
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
def user_agent(self) -> str:
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]:
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]:
return api.load_cached_stations()
+144 -19
View File
@@ -2,13 +2,15 @@
``https://de1.api.radio-browser.info/json/stations/search`` is keyless, CORS-open
and public-domain data. It knows nothing about what a station is playing —
there is no per-station history — so this catalogue is a *directory*: three
hundred of the most-clicked working stations, ready to tune into. Whatever the
relay puts in its stream is what you hear; the app has no metadata to show.
there is no per-station history — so this catalogue is a *directory*: the most
clicked working stations, a page at a time. Whatever the relay puts in its
stream is what you hear; the app has no metadata to show.
"""
from __future__ import annotations
import asyncio
import re
from typing import Any
from urllib.parse import urlencode
@@ -16,18 +18,34 @@ import httpx
from ... import config
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"
BY_UUID_URL = "https://de1.api.radio-browser.info/json/stations/byuuid"
#: Three hundred is a comfortable directory: enough to browse, quick to fetch.
STATION_LIMIT = 300
#: Stations per directory page. The app loads the first page at startup and
#: 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 = {
"hidebroken": "true",
"order": "clickcount",
"reverse": "true",
"limit": str(STATION_LIMIT),
}
_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):
return []
stations: list[Station] = []
@@ -92,22 +110,129 @@ def parse_stations(payload: Any) -> list[Station]:
continue
seen.add(station.id)
stations.append(station)
stations.sort(key=lambda station: station.title.casefold())
if sort:
stations.sort(key=lambda station: station.title.casefold())
return stations
def _page_url(offset: int) -> str:
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:
payload = await fetch_json(client, _page_url(offset))
except RuntimeError as exc:
raise RuntimeError(f"could not fetch the Radio Browser directory: {exc}") from None
stations = parse_stations(payload, sort=False)
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)
return StationPage(stations=stations, next_offset=next_offset)
async def fetch_stations(client: httpx.AsyncClient) -> list[Station]:
url = f"{API_URL}?{urlencode(_PARAMS)}"
try:
payload = await fetch_json(client, url)
except RuntimeError as exc:
raise RuntimeError(f"could not fetch the Radio Browser directory: {exc}") from None
stations = parse_stations(payload)
if not stations:
page = await fetch_station_page(client, 0)
if not page.stations:
raise RuntimeError("Radio Browser returned no stations")
config.write_json(config.channels_cache_path("radiobrowser"), payload)
return stations
return page.stations
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
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;
everything else, including the other two corners, is that grey.
run a short fade out of the palette's hot end and back into the border 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
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 functools import cache
from rich.cells import cell_len
from rich.console import Group, RenderableType
from rich.text import Text
from textual.widget import Widget
from ..palettes import DEFAULT_PALETTE, PALETTES
from ..palettes import DEFAULT_PALETTE, PALETTES, brightness
from .common import active_stops
#: The frame itself: one grey, the same grey as the scrollbar thumb.
@@ -26,20 +29,32 @@ EDGE = "bright_black"
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:
"""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
its hot end, so the corner glows in the palette's brightest colour and then
hands over to the plain border grey.
The fade is short — four cells — and runs from the brightest colour the
palette's hot end offers back into the plain border grey.
"""
if not stops:
stops = PALETTES[DEFAULT_PALETTE]
ramp = fade_ramp(tuple(stops))
distance = max(0, distance)
if distance >= FADE_CELLS:
return EDGE
index = len(stops) - 1 - distance
return stops[index] if index >= 0 else EDGE
return ramp[distance] if distance < len(ramp) else EDGE
def inner_size(width: int, height: int) -> tuple[int, int]:
@@ -119,7 +134,6 @@ def wrap(
width = max(4, width)
height = max(2, height)
inner_width, inner_height = inner_size(width, height)
rows = len(lines[:inner_height])
framed: list[Text] = [top(width, title, stops)]
for index, line in enumerate(lines[:inner_height]):
@@ -129,12 +143,12 @@ def wrap(
line = line.copy()
line.truncate(inner_width, overflow="ellipsis")
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)
pad = inner_width - cell_len(line.plain)
if pad > 0:
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)
while len(framed) < height - 1:
index = len(framed) - 1
+1 -1
View File
@@ -35,7 +35,7 @@ HELP_ROWS: tuple[tuple[str, str], ...] = (
("m", "mute / unmute"),
("f", "favourite — favourites float to the top"),
("/", "filter stations by title, genre or description"),
("esc", "clear the filter / close overlays"),
("esc", "close the search box; esc again clears the filter"),
(
"v",
"cycle visualiser: mirror → bars → wave → waterfall → radial → braille",
+4 -1
View File
@@ -88,7 +88,10 @@ class StationList(Widget):
view = app.view
if not view:
message = "no stations match" if app.query else "loading stations…"
if getattr(app, "search_pending", False):
message = "searching the directory…"
else:
message = "no stations match" if app.query else "loading stations…"
return frame.wrap(
[Text(truncate(message, inner_width), style="dim italic")],
width,
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1793726008,
"narHash": "sha256-jQp1wKvbwWdLBASTXkSi/VYmDJ5AbBi1WvcMt9HWJC8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "1d060086d60cb76a40cf37109f979d88a5bd6396",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+188
View File
@@ -0,0 +1,188 @@
{
description = "beefy-player — a flashy terminal player for internet radio";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs =
{ self, nixpkgs }:
let
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
# Things the app shells out to at runtime: mpv for audio, ffmpeg for the
# spectrum capture, and parec/pactl from PulseAudio for the monitor source
# (PipeWire speaks the same protocol).
runtimeDeps =
pkgs:
with pkgs;
[
mpv
ffmpeg
]
++ lib.optionals stdenv.hostPlatform.isLinux [ pulseaudio ];
beefy-player =
pkgs:
let
py = pkgs.python3Packages;
in
py.buildPythonApplication {
pname = "beefy-player";
version = "1.2.2";
pyproject = true;
src = self;
build-system = [ py.hatchling ];
dependencies =
with py;
[
textual
rich
httpx
numpy
pillow
]
++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [ dbus-next ];
nativeBuildInputs = [ pkgs.makeWrapper ];
nativeCheckInputs = with py; [
pytestCheckHook
pytest-asyncio
];
# The suite is offline (catalogues are cached fixtures).
doCheck = true;
pythonImportsCheck = [ "beefy_player" ];
makeWrapperArgs = [
"--prefix"
"PATH"
":"
(pkgs.lib.makeBinPath (runtimeDeps pkgs))
];
meta = {
description = "Flashy terminal player for internet radio, with a live spectrum analyser";
homepage = "https://tildeforge.dev/beefcarver/beefy-player";
license = pkgs.lib.licenses.wtfpl;
mainProgram = "beefy-player";
platforms = pkgs.lib.platforms.unix;
};
};
# One module body, two install targets: a NixOS system adds the package
# to environment.systemPackages, Home Manager to home.packages.
mkModule =
install:
{
config,
lib,
pkgs,
...
}:
let
cfg = config.programs.beefy-player;
in
{
options.programs.beefy-player = {
enable = lib.mkEnableOption "beefy-player, a terminal radio player";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
defaultText = lib.literalMD "the `beefy-player` package from this flake";
description = "The beefy-player package to use.";
};
};
config = lib.mkIf cfg.enable (lib.setAttrByPath install [ cfg.package ]);
};
in
{
packages = forAllSystems (
pkgs:
let
pkg = beefy-player pkgs;
in
{
default = pkg;
beefy-player = pkg;
}
);
apps = forAllSystems (pkgs: {
default = {
type = "app";
program = "${beefy-player pkgs}/bin/beefy-player";
meta.description = "Flashy terminal player for internet radio";
};
});
checks = forAllSystems (pkgs: {
beefy-player = beefy-player pkgs;
});
devShells = forAllSystems (
pkgs:
let
inherit (pkgs) lib;
in
{
# No FHS needed here: `nix develop` puts libstdc++ on LD_LIBRARY_PATH
# so uv's manylinux wheels load, unlike the plain `nix-shell` case in
# shell.nix.
default = pkgs.mkShell {
packages = runtimeDeps pkgs ++ [
pkgs.python3
pkgs.uv
pkgs.ruff
pkgs.git
pkgs.cacert
];
env = {
UV_PYTHON = "${pkgs.python3}/bin/python3";
UV_PYTHON_DOWNLOADS = "never";
UV_PROJECT_ENVIRONMENT = "${toString ./.}/.venv";
UV_LINK_MODE = "copy";
SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
};
LD_LIBRARY_PATH = lib.makeLibraryPath [
pkgs.stdenv.cc.cc.lib
pkgs.zlib
];
shellHook = ''
echo "beefy-player dev shell — uv sync, uv run pytest, uv run beefy-player"
'';
};
}
);
nixosModules.default = mkModule [
"environment"
"systemPackages"
];
nixosModules.beefy-player = self.nixosModules.default;
homeModules.default = mkModule [
"home"
"packages"
];
homeModules.beefy-player = self.homeModules.default;
formatter = forAllSystems (pkgs: pkgs.nixfmt);
overlays.default = final: _prev: {
beefy-player = beefy-player final;
};
};
}
+51
View File
@@ -0,0 +1,51 @@
class BeefyPlayer < Formula
include Language::Python::Virtualenv
desc "Terminal player for internet radio with a live spectrum analyser"
homepage "https://tildeforge.dev/beefcarver/beefy-player"
url "https://tildeforge.dev/beefcarver/beefy-player/releases/download/v1.2.2/beefy-player-1.2.2.tar.gz"
sha256 "0000000000000000000000000000000000000000000000000000000000000000" # TODO: release asset
license "WTFPL"
head "https://tildeforge.dev/beefcarver/beefy-player.git", branch: "main"
depends_on "[email protected]"
# numpy and pillow are large native builds. Depending on Homebrew's own
# formulae (which have bottles) keeps them out of the app's virtualenv build;
# the venv sees them through system site-packages.
depends_on "numpy"
depends_on "pillow"
# Runtime tools the app shells out to: mpv for audio, ffmpeg for the stream
# decode fallback of the spectrum analyser. Both are optional at runtime --
# the UI degrades gracefully -- but a radio player without them is not much fun.
depends_on "ffmpeg"
depends_on "mpv"
# Tells `brew update-python-resources` what to resolve: numpy/pillow come from
# the formulae above, and dbus-next is marker-gated to Linux upstream so it
# has to be requested explicitly for the Linux side of the tap.
pypi_packages exclude_packages: "numpy,pillow", extra_packages: "dbus-next"
def install
# system_site_packages is the default, so the excluded numpy/pillow from the
# formulae are importable from the virtualenv.
virtualenv_install_with_resources
end
def caveats
<<~EOS
The spectrum analyser follows what you hear. On macOS 14.2+ grant your
terminal permission under System Settings > Privacy & Security > System
Audio Recording, otherwise it falls back to decoding the stream.
Inside tmux, install `tmux` for image passthrough.
EOS
end
test do
assert_match version.to_s, shell_output("#{bin}/beefy-player --version")
# --offline keeps the test hermetic: cached or bundled stations only.
assert_match(/^# /, shell_output("#{bin}/beefy-player --offline --list"))
end
end
+120
View File
@@ -0,0 +1,120 @@
# Homebrew packaging for beefy-player
A Homebrew **tap** is just a second git repository that Homebrew clones next to
homebrew/core. There is no central submission, no review and no binary host:
`brew` reads the Ruby formula from the tap, downloads the pinned source tarball
and installs it on the user's machine.
This directory is the staging area for the tap repository, which lives at
**https://tildeforge.dev/beefcarver/homebrew-tap** (repo name `homebrew-tap`, so
the tap name is `beefcarver/tap`).
Because Homebrew's one-argument `brew tap` assumes GitHub, a Forgejo tap has to
be tapped with its URL, and Homebrew 6+ also wants an explicit trust.
## Install (user side)
```bash
brew tap beefcarver/tap https://tildeforge.dev/beefcarver/homebrew-tap
brew trust --formula beefcarver/tap/beefy-player
brew install beefcarver/tap/beefy-player
```
`brew update` refreshes the tap from then on; `brew upgrade` picks up new
versions like any core formula. If the tap is mirrored to GitHub later,
`brew install beefcarver/tap/beefy-player` becomes a one-liner, because that
form auto-taps from GitHub and trusts only the formula it installs.
## Publishing the tap (maintainer side)
### 1. One tag per release, with a release asset
The formula pins a stable URL plus a SHA-256, so it needs an immutable tarball.
Do **not** point it at Forgejo's `/archive/v$VERSION.tar.gz` URL: those archives
are generated on demand and are not guaranteed byte-stable. Attach a tarball to
the release instead (Forgejo serves it at `/releases/download/<tag>/<file>`):
```bash
VERSION=$(uv run python -c 'import beefy_player; print(beefy_player.__version__)')
git archive --format=tar.gz --prefix="beefy-player-$VERSION/" \
-o "beefy-player-$VERSION.tar.gz" "v$VERSION"
sha256sum "beefy-player-$VERSION.tar.gz"
```
Create the tag on the mirror (`git push tildeforge "v$VERSION"`), upload the
tarball to the `v$VERSION` release, then paste the URL and checksum into
`Formula/b/beefy-player.rb`.
### 2. Resolve the Python resources
Every Python dependency (and its recursive dependencies) that is not provided by
another formula must be declared as a `resource` so Homebrew installs with
dependency resolution disabled. Never write these by hand:
```bash
brew tap beefcarver/tap /path/to/homebrew-tap # local clone, or the URL
brew update-python-resources --print-only beefcarver/tap/beefy-player
brew update-python-resources beefcarver/tap/beefy-player
```
`pypi_packages exclude_packages: "numpy,pillow"` keeps the two native builds out
of the resource list — they come from the `numpy`/`pillow` formulae, which ship
bottles, and the virtualenv picks them up through system site-packages.
`extra_packages: "dbus-next"` makes sure the Linux-only dependency is recorded
even though the resolver runs on macOS.
In a third-party tap, `--ignore-errors` records anything that cannot be resolved
and leaves a `RESOURCE-ERROR` comment; the official taps do not allow that, a
personal one does.
### 3. Build, test and audit locally
```bash
brew install --build-from-source beefcarver/tap/beefy-player
brew test beefcarver/tap/beefy-player
brew audit --strict beefcarver/tap/beefy-player
brew style beefcarver/tap/beefy-player
```
The formula's `test do` block runs `--version` and `--offline --list`, so it
needs no network and no audio device.
### 4. Push
```bash
git add Formula/b/beefy-player.rb && git commit -m 'beefy-player 1.2.2' && git push
```
## Version bumps
For each release: tag, upload the tarball, update `url`/`sha256`, rerun
`brew update-python-resources` (only needed when the dependency set or pins
changed), install from source, test, push. `brew bump-formula-pr` only works
against GitHub-hosted taps, so on Forgejo the bump is a `sed` plus a re-test.
Optional `livecheck` support, so `brew livecheck --tap beefcarver/tap` can spot
new tags:
```ruby
livecheck do
url "https://tildeforge.dev/beefcarver/beefy-player/tags"
regex(/^v?(\d+(?:\.\d+)+)$/i)
end
```
## Bottles (optional)
Without a `bottle do` block users build from source — for this app that means
filling a virtualenv with pure-Python resources while `numpy`, `pillow`, `mpv`
and `ffmpeg` arrive as bottles, so it is quick. Prebuilt bottles need Homebrew's
GitHub Actions workflows, which cannot run on Forgejo; skip them unless the tap
is also mirrored to GitHub.
## Why a tap and not homebrew-core
homebrew-core requires a tagged, immutable source URL and enough notability to
be accepted at all (roughly 75+ stars / 30+ forks, or coverage that makes a
project notable). A tap has none of those constraints, which is why upstream
taps are the normal route for a project this size. The formula here follows
core's conventions, so it could be submitted there later unchanged if the
project qualifies.
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "beefy-player"
version = "1.1.0"
version = "1.2.2"
description = "A flashy terminal player for internet radio — live spectrum analyser, MPRIS / macOS Now Playing support, song history and pluggable station catalogues (SomaFM, Radio Paradise, dublab, Rinse FM and more)"
readme = "README.md"
license = "WTFPL"
+314 -3
View File
@@ -8,7 +8,8 @@ import pytest
from beefy_player import config
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.help import HelpScreen
from beefy_player.widgets.stage import StagePanel
@@ -70,8 +71,10 @@ async def test_boot_navigation_filter_favourite_visualizer_help() -> None:
await pilot.pause()
assert app.current is not None
assert app.current.id == "defcon"
assert app.query == ""
assert len(app.view) == len(app.stations)
assert app.query == "defcon"
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")
app.action_favorite()
@@ -100,6 +103,314 @@ async def test_boot_navigation_filter_favourite_visualizer_help() -> None:
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
async def test_stage_renders_history_and_listener_count() -> None:
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 rich.text import Text
from beefy_player.palettes import PALETTES
from beefy_player.palettes import PALETTES, brightness
from beefy_player.widgets import frame
#: The original ladder, used as a fixed reference in these tests.
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]]:
@@ -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:
spans = styles_of(frame.top(30, stops=STOPS))
assert spans[0] == ("╭", frame.EDGE) # plain grey corner
# the fade runs into the top-right corner, hot end last
assert [style for _text, style in spans[-4:-1]] == [
STOPS[-4],
STOPS[-3],
STOPS[-2],
]
# the fade runs into the top-right corner, brightest cell last
assert [style for _text, style in spans[-4:-1]] == [RAMP[3], RAMP[2], RAMP[1]]
assert spans[-1] == ("╮", f"bold {HOT}")
def test_only_the_bottom_left_corner_fades_on_the_bottom_edge() -> None:
spans = styles_of(frame.bottom(30, STOPS))
assert spans[0] == ("╰", f"bold {HOT}")
assert [style for _text, style in spans[1:4]] == [
STOPS[-2],
STOPS[-3],
STOPS[-4],
]
assert [style for _text, style in spans[1:4]] == [RAMP[1], RAMP[2], RAMP[3]]
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,
STOPS[-4],
STOPS[-3],
STOPS[-2],
RAMP[3],
RAMP[2],
RAMP[1],
]
# right column fades down from the top-right corner
assert [frame.side_style(row, 10, "right", STOPS) for row in range(10)] == [
STOPS[-2],
STOPS[-3],
STOPS[-4],
RAMP[1],
RAMP[2],
RAMP[3],
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("│")
# the original renderable is left untouched
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,
PALETTE_ORDER,
PALETTES,
brightness,
palette_stops,
)
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"])
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"]
assert frame.corner_fade(0, stops) == stops[-1]
assert frame.corner_fade(1, stops) == stops[-2]
assert frame.corner_fade(3, stops) == stops[-4]
# the fade is short: past four cells it is just the border grey
assert frame.corner_fade(4, stops) == frame.EDGE
ramp = frame.fade_ramp(stops)
assert frame.corner_fade(0, stops) == ramp[0]
assert frame.corner_fade(1, stops) == ramp[1]
# the fade is short: past the ramp it is just the border grey
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
+2 -2
View File
@@ -2,13 +2,13 @@ from __future__ import annotations
import os
import queue
import sys
import time
from pathlib import Path
from beefy_player.player import Player
FAKE_MPV = '''#!/usr/bin/env python3
import json, os, socket, sys, time
FAKE_MPV = f"#!{sys.executable}\n" + '''import json, os, socket, sys, time
sock_path = None
volume = 70
+115 -4
View File
@@ -66,25 +66,136 @@ def test_hls_streams_survive_as_direct_urls() -> None:
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
async def test_fetch_stations_caches_the_directory() -> None:
payload = [ENTRY, {**ENTRY, "stationuuid": "x", "name": "Another"}]
def handler(request: httpx.Request) -> httpx.Response:
assert "hidebroken=true" in str(request.url)
assert "limit=300" in str(request.url)
params = dict(request.url.params)
assert params["hidebroken"] == "true"
assert params["limit"] == str(api.PAGE_SIZE)
assert params["offset"] == "0"
return httpx.Response(200, json=payload)
provider = RadioBrowserProvider()
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
stations = await provider.fetch_stations(client)
assert [station.title for station in stations] == ["Another", "France Info"]
assert [station.title for station in stations] == ["France Info", "Another"]
assert [station.title for station in provider.load_cached_stations()] == [
"Another",
"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
async def test_a_failed_fetch_does_not_kill_the_catalogue() -> None:
def handler(request: httpx.Request) -> httpx.Response:
+4 -4
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import sys
import time
from pathlib import Path
@@ -8,8 +9,7 @@ import numpy as np
from beefy_player import spectrum as spectrum_module
from beefy_player.spectrum import Frame, SpectrumEngine
PCM_SCRIPT = """#!/usr/bin/env python3
import math, struct, sys, time
PCM_SCRIPT = f"#!{sys.executable}\n" + """import math, struct, sys, time
sr = 44100
samples = [int(0.6 * 32767 * math.sin(2 * math.pi * 1000 * i / sr)) for i in range(4096)]
chunk = struct.pack("<%dh" % len(samples), *samples)
@@ -303,7 +303,7 @@ def test_tap_audio_is_downmixed_and_retuned() -> None:
def test_dead_monitor_falls_back_instead_of_flat_bars(tmp_path) -> None:
script = tmp_path / "silent-parec"
script.write_text(
"#!/usr/bin/env python3\n"
f"#!{sys.executable}\n"
"import sys\n"
"block = b'\\0' * 4096\n"
"for _ in range(1000):\n"
@@ -333,7 +333,7 @@ def test_monitor_survives_silence_after_audio(tmp_path, monkeypatch) -> None:
# replaced by the drifting stream tap) for the rest of the session.
script = tmp_path / "parec"
script.write_text(
"#!/usr/bin/env python3\n"
f"#!{sys.executable}\n"
"import math, struct, sys, time\n"
"sr = 44100\n"
"n = 2048\n"
Generated
+1 -1
View File
@@ -32,7 +32,7 @@ wheels = [
[[package]]
name = "beefy-player"
version = "1.1.0"
version = "1.2.2"
source = { editable = "." }
dependencies = [
{ name = "dbus-next", marker = "sys_platform == 'linux'" },