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 1888 additions and 602 deletions

No files matched your search

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