Compare commits

...
30 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
beefcarver f96923b5b7 chore(release): 1.1.0 2026-11-04 08:56:10 -05:00
beefcarver 7ba2d50503 fix(ui): keep the help and catalogue menus inside the screen 2026-11-04 08:56:04 -05:00
beefcarver 43ddd8666b feat(palette): sixteen ANSI colour ladders, cycled with c 2026-11-04 08:55:51 -05:00
beefcarver 253b462df1 chore(release): 1.0.0 2026-11-03 20:38:34 -05:00
beefcarver 5eb60d378c feat(catalogue): tune a single-station catalogue on switch 2026-11-03 20:31:18 -05:00
beefcarver 0219df454c feat(ui): draw pane frames, page keys and a scroll thumb 2026-11-03 20:25:02 -05:00
beefcarver 49f7efe0d2 feat(providers): add nine catalogues and the Radio Browser directory 2026-11-03 20:24:53 -05:00
beefcarver a3ad693132 fix(ui): keep the catalogue picker title and hint apart 2026-11-03 18:44:57 -05:00
beefcarver b3bd8c1e9f chore(tools): key out white backgrounds in the ANSI art converter 2026-11-03 18:44:52 -05:00
beefcarver e14a349c2d fix(artwork): letterbox images and back alpha art with black 2026-11-03 18:44:47 -05:00
beefcarver 50a9a735f7 feat(bassdrive): add the drum & bass relay and a shared wordmark font 2026-11-03 18:44:40 -05:00
beefcarver 84ec3b08f6 docs(agents): record the uv sync requirement for checked-out venvs 2026-11-03 17:51:32 -05:00
beefcarver ba114599d0 fix(stage): name the presenter instead of a lone artwork glyph 2026-11-03 17:51:29 -05:00
beefcarver b31ad3f6fb feat(ui): add catalogue picker, schedule pane and per-provider hero 2026-11-03 17:51:27 -05:00
beefcarver 058d336a2c feat(tilderadio): add the tildeverse community stream as a catalogue 2026-11-03 17:51:23 -05:00
77 changed files with 8151 additions and 621 deletions

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
# AGENTS.md
Notes for coding agents working in this repository.
## Re-sync the uv environment before doing anything
The checked-out `.venv` is **not** portable. It is built by `uv sync`, and its
`python` symlink points at a uv-managed interpreter path belonging to whichever
machine created it (for example `/home/node/.local/share/uv/python/...`). When
the repository is re-synced from the host, that interpreter is gone, the
symlink dangles, and every `.venv/bin/*` command fails with:
.venv/bin/python: No such file or directory
So always re-sync first — before tests, lint, the app, or any tooling:
```bash
uv sync
```
If it still fails, rebuild the environment once:
```bash
rm -rf .venv && uv sync
```
There is no project Python on `PATH`; use `uv run <command>` or the freshly
synced `.venv/bin/python` explicitly.
## Commands
```bash
uv sync # resync the environment (do this first)
uv run pytest # full test suite
uvx ruff check . # lint (ruff is not a project dependency)
uv run beefy-player --list # station ids for the current catalogue
uv run python tools/ansi_art.py ... # regenerate a provider's header art
```
+212 -377
View File
@@ -3,259 +3,208 @@
![beefy-player demo](assets/beefy-player-demo.gif)
A flashy terminal player for **internet radio** — a live spectrum analyser,
song history, and full desktop media integration, drawn with your terminal's
own ANSI palette on the terminal's default background so it fits any theme
(including transparent terminals).
song history and album art, with desktop media integration on Linux and macOS.
It draws with your terminal's own ANSI palette on the terminal's default
background, so it fits any theme, including transparent terminals.
The station catalogue is pluggable. Beefy Player ships with
**[SomaFM](https://somafm.com)** (its first and, at the moment, only provider),
and the UI never talks to a station catalogue directly: it asks a
[provider](#providers) for stations, song history and stream URLs. Adding
another internet-radio catalogue is a self-contained module, not a rewrite.
It runs the same way on **Linux** (PulseAudio/PipeWire) and **macOS**
(CoreAudio, iTerm2/Kitty/WezTerm): MPRIS on the desktop bus where that exists,
macOS Now Playing (Control Center and the media keys) where it does not.
No existing radio client is used: the station list, playlists, song history,
audio control, spectrum visualiser and media-session integration are all
implemented here from public endpoints and open protocols.
Thirteen station catalogues ship today, from SomaFM's curated channels to the
62,000-station Radio Browser directory, loaded as you scroll and searched live
as you type. Press `p` to switch catalogue without restarting.
## Features
- **Live spectrum analyser** — 72 log-spaced bands with peak caps, a moving
gradient taken from your terminal's ANSI palette and a symmetric mirrored
spectrum. Six modes (`v`): mirrored bars, plain bars, oscilloscope,
scrolling waterfall spectrogram, a radial spectrum wrapped around the
centre, and a high-resolution braille oscilloscope (2×4 dots per cell).
- **Synced spectrum, not a guess** — by default the analyser records your
actual audio output, so the bars follow what you hear (`SYNC`): the
PulseAudio/PipeWire monitor via `parec` on Linux, and a native Core Audio
process tap on macOS (no driver, no BlackHole, nothing to install). Without
an output capture it falls back to a muted second `ffmpeg` decode of the
stream (`FFT`, can drift), then to a synthesized display (`SIM`).
- **Real artwork on iTerm2, Kitty and WezTerm** — Kitty uses the kitty
graphics protocol; iTerm2 (including on macOS) and WezTerm use their native
iTerm2 inline-image protocol. Images occupy exactly the ON AIR art cell box
and fall back to the procedural ANSI mosaic for other terminals, failed
loads, or blocked graphics passthrough. Images are cached in
`~/.cache/beefy-player/artwork`.
- **Now playing** — artist, title, album, DJ, listeners, format/bitrate and
"played N minutes ago", with procedural ANSI-shaded album art that changes
per track. When the current track can be found on Bandcamp, a clickable
**Bandcamp ↗** link appears below it; otherwise the line stays hidden.
- **Last played** — the full recent history, newest first.
- **Every station** — with SomaFM that is the whole `channels.json` roster
(≈50 channels), live listener counts and genre search.
- **Favourites** that float to the top, remembered between runs.
- **Pluggable catalogues** — one `providers` module per radio network, selected
with `--provider` and remembered in the config file.
- **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.
- **Theme-aware** — every accent is emitted as native ANSI (not RGB), and the
background is the terminal default (`SGR 49`), so transparent terminals stay
transparent and the TUI adopts your shell/terminal colour scheme.
- **Graceful degradation** — works with cached data offline, falls back to a
bundled station list, reconnects across ICE mirrors automatically, and uses
`ffplay` if `mpv` is missing.
- **Live spectrum analyser** — 72 log-spaced bands with peak caps, a palette
gradient and a mirrored spectrum. Six modes (`v`): mirrored bars, plain bars,
oscilloscope, scrolling waterfall, radial spectrum and a high-resolution
braille oscilloscope.
- **Synced to what you hear** — the analyser listens to your actual audio
output by default, so the bars follow the music. If that is unavailable it
falls back to decoding the stream, then to a simulated display.
- **Now playing and history** — artist, title, album, DJ, listeners, bitrate
and "played N minutes ago", plus the full recent history. A clickable
**Bandcamp ↗** link appears when the current track can be found there.
- **Real album art** — native images in Kitty, iTerm2 and WezTerm; an
ANSI-shaded mosaic in every other terminal.
- **Thirteen station catalogues** — SomaFM (≈50 channels), Radio Paradise,
tilderadio, Bassdrive, dublab, Rinse FM, Refuge Worldwide, LYL Radio, The Lot
Radio, Cashmere Radio, Datafruits, aNONradio and the Radio Browser directory
(loaded as you scroll in popularity order, with live search across all
62,000).
- **Favourites, search and schedules** — favourites float to the top and are
remembered between runs, including directory stations outside the loaded
page; search by title, genre or description, with live server-side search
across the Radio Browser directory; single-stream stations show their
broadcast schedule instead of a one-item list.
- **Desktop media integration** — MPRIS on Linux (GNOME/KDE widgets, lock
screens, `playerctl` and the media keys) and Now Playing on macOS (Control
Center and the media keys).
- **Sixteen palettes, theme-aware** — every colour is native ANSI against the
terminal's default background, so transparent terminals stay transparent and
the TUI follows your colour scheme. Press `c` to cycle palettes.
- **Graceful offline** — cached stations and schedules, a bundled fallback
list, automatic mirror failover and `ffplay` when `mpv` is missing.
## Requirements
## Installation
`beefy-player` is a normal Python application: there is nothing to compile and
no distro-specific packaging. It runs on any Linux distribution or macOS with
Python **3.10+** and a few command-line audio tools on `$PATH`.
### Nix
| tool | why |
| ---- | --- |
| `python3` | runtime (3.10 or newer; no exact version pin) |
| [`uv`](https://docs.astral.sh/uv/) | creates the venv and installs every Python dependency from `uv.lock` |
| `mpv` | playback and JSON IPC control |
| `ffmpeg` | muted stream decode for the fallback analyser |
| `parec` / `pactl` *(Linux)* | synced monitor capture for the spectrum (`pulseaudio-utils`; works with PipeWire) |
| D-Bus session bus *(Linux)* | MPRIS desktop media integration (`playerctl` is handy for testing) |
On macOS the spectrum capture is part of the app: it uses the Core Audio
process-tap API (macOS 14.2 or newer), so there is no loopback driver to
install.
On macOS the media integration needs nothing extra: mpv is what the system's
Now Playing session (Control Center, media keys) talks to.
Install `uv` once (skip if it is already there):
The flake builds the package, runs the test suite and wraps everything the app
calls at runtime (`mpv`, `ffmpeg`, and PulseAudio's `parec`/`pactl` — PipeWire
speaks the same protocol):
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
nix run git+https://tildeforge.dev/beefcarver/beefy-player # try it
nix profile install git+https://tildeforge.dev/beefcarver/beefy-player # install it
```
`brew install uv`, `pipx install uv`, or a distro package also work. If your
system Python is older than 3.10, let uv manage one: `uv python install 3.12`.
In a NixOS configuration:
### Linux
```nix
{
inputs.beefy-player.url = "git+https://tildeforge.dev/beefcarver/beefy-player";
Install these with your package manager. Names are usually:
outputs = { nixpkgs, beefy-player, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
modules = [
beefy-player.nixosModules.default
{
programs.beefy-player.enable = true;
}
];
};
};
}
```
Or, for a single user with Home Manager:
```nix
{
inputs.beefy-player.url = "git+https://tildeforge.dev/beefcarver/beefy-player";
outputs = { home-manager, beefy-player, ... }: {
homeConfigurations.me = home-manager.lib.homeManagerConfiguration {
modules = [
beefy-player.homeModules.default
{
programs.beefy-player.enable = true;
}
];
};
};
}
```
Both modules add one option, `programs.beefy-player.package`, so a pinned or
overridden build can be substituted for the flake's own. The raw package
(`packages.${system}.default`) and an overlay (`overlays.default`) are exported
too. From a checkout, `nix develop` gives you `uv`, `ruff` and the audio tools
for the usual `uv sync` / `uv run pytest` workflow; `nix build` builds and
tests the package.
### Arch Linux
The `aur/PKGBUILD` here is AUR-ready, but the package is not published in the
AUR: the AUR has closed new sign-ups, so there is no account to push it to.
Build and install it from a checkout instead:
```bash
# Debian / Ubuntu
sudo apt install python3 mpv ffmpeg pulseaudio-utils dbus playerctl
# Fedora (enable RPM Fusion for ffmpeg if needed)
sudo dnf install python3 mpv ffmpeg pulseaudio-utils dbus-tools playerctl
# Arch / Manjaro
sudo pacman -S python mpv ffmpeg libpulse dbus playerctl
# openSUSE
sudo zypper install python3 mpv ffmpeg pulseaudio-utils dbus-1-tools playerctl
git clone https://tildeforge.dev/beefcarver/beefy-player
cd beefy-player/aur
makepkg -si # needs base-devel; builds, runs the test suite, installs
```
For other distributions, install the equivalents. On a headless machine you
can skip the audio/D-Bus tools and use `--no-audio` for a UI-only demo, though
the synced spectrum needs `parec`/`pactl`.
Once the AUR accepts new accounts again, the package can be published and
`paru -S beefy-player` / `yay -S beefy-player` will work; until then `makepkg`
is the only route. The `aur/PKGBUILD` is tracked and kept in sync with every
release either way.
### macOS
The Python side is pulled in automatically. For audio, the output-synced
spectrum and image passthrough through tmux, install the optional tools too:
```bash
brew install mpv ffmpeg uv # ffplay ships with ffmpeg
sudo pacman -S mpv ffmpeg libpulse tmux
```
That is enough to run: playback, artwork in iTerm2/Kitty/WezTerm, the synced
Core Audio spectrum tap and the macOS Now Playing integration all work out of
the box.
The first time the analyser starts it asks macOS for **System Audio
Recording** permission — a one-off prompt for your terminal (iTerm2, Kitty,
WezTerm, …). Approve it under *System Settings → Privacy & Security → System
Audio Recording*; no loopback driver, Multi-Output Device or audio routing is
needed. If the permission is denied the tap only ever hands out silence, the
analyser notices and falls back to `FFT` instead of showing dead bars.
The tap records the **default output device**, which is also what mpv uses; if
you point mpv elsewhere with `BEEFY_MPV_ARGS='--audio-device=...'` the
analyser falls back to `FFT`.
Terminal image rendering uses [Pillow](https://python-pillow.org/) and is
implemented directly (Kitty TGP + iTerm2 OSC 1337) — no terminal probing.
## Providers
A *provider* is one station catalogue. It knows how to fetch the station list,
how to fetch a station's recently played songs, and how to turn a station into
a stream URL. Everything else — the UI, the player, the spectrum, the media
session — is provider-neutral.
```python
# beefy_player/providers/base.py
class RadioProvider(ABC):
id, name, homepage, brand, tagline, station_source, history_source, user_agent
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]: ...
def fallback_urls(self, station) -> list[str]: ...
async def stream_candidates(self, client, station) -> list[str]: ...
```
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`.
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>` (or set `"provider"` in
`~/.config/beefy-player/config.json`).
`RadioProvider.stream_candidates()` already implements the common strategy —
resolve the station's playlists (with an on-disk cache), then try
`fallback_urls()` — so most providers only have to supply the catalogue,
history and mirror URLs.
## Run
From the repository root, sync the environment and start the app:
### From source
```bash
uv sync # create .venv and install exactly what uv.lock pins
uv run beefy-player # run inside that environment
uv sync
uv run beefy-player
```
You do **not** need to activate the venv. `uv run` uses `.venv` automatically,
and re-syncs it first if `pyproject.toml` or `uv.lock` changed. If you prefer a
traditional shell, run `source .venv/bin/activate` and then `beefy-player`.
### Keeping the uv environment in sync
`uv sync` is the command to remember. It is incremental, safe to re-run, and
should be run after pulling changes, switching branches, or editing
`pyproject.toml`:
## Usage
```bash
uv sync # make .venv match uv.lock
uv sync --frozen # strict: fail if uv.lock is out of date, never rewrite it
uv lock --upgrade # refresh uv.lock to the newest allowed versions
uv sync --upgrade # upgrade dependencies and sync .venv in one step
rm -rf .venv && uv sync # last resort if the venv was built by an older Python
beefy-player
```
Useful flags:
```bash
uv run beefy-player --provider somafm # pick a station catalogue
uv run beefy-player --play # start the last station immediately
uv run beefy-player --station defcon # pick a station
uv run beefy-player --volume 55
uv run beefy-player --offline # no network; cached/bundled stations
uv run beefy-player --no-mpris # no MPRIS / Now Playing, no media keys
uv run beefy-player --list # print station ids
uv run beefy-player --no-audio # UI demo without mpv/ffmpeg
uv run beefy-player --spectrum-source monitor # force synced output capture
uv run beefy-player --spectrum-source stream # force the muted ffmpeg tap
BEEFY_IMAGES=0 uv run beefy-player # force the ANSI artwork mosaic
BEEFY_IMAGES=tgp uv run beefy-player # force Kitty/TGP artwork
BEEFY_IMAGES=iterm2 uv run beefy-player # force iTerm2/WezTerm artwork
```
Environment variables are prefixed `BEEFY_`; the pre-rename `SOMAFM_` spellings
(`SOMAFM_IMAGES`, `SOMAFM_MPV_ARGS`, `SOMAFM_SPECTRUM_DEVICE`) are still
honoured, so an existing shell profile keeps working.
The UI wants roughly **84×24**; it also works smaller, just less pretty.
## Keys
| flag | what it does |
| --- | --- |
| `--provider <slug>` | pick a station catalogue |
| `--station <id>` | pick a station (see `--list`) |
| `--play` | tune the last station right away |
| `--list` | print station ids for the current catalogue |
| `--volume <0-100>` | set the initial volume |
| `--offline` | no network; use cached and bundled stations |
| `--no-audio` | UI demo without `mpv` / `ffmpeg` |
| `--no-mpris` | no MPRIS / Now Playing, no media keys |
| `--spectrum-source monitor\|stream` | force synced output capture or the stream tap |
| key | action |
| -------------- | --------------------------------------------- |
| `↑` / `↓`, `j` / `k` | move through stations |
| `enter` | tune in to the selected station |
| `space` | play / pause |
| `s` | stop |
| `]` / `[` | next / previous station (tunes immediately) |
| `+` / `-` | volume |
| `m` | mute |
| `f` | favourite (favourites sort to the top) |
| `/` | filter by title, genre, description |
| `esc` | clear filter / close search |
| `v` | visualiser: mirror → bars → wave → waterfall → radial → braille |
| `r` | refresh stations + song history |
| `g` / `G` | top / bottom |
| `?` | help |
| `q` | quit |
Environment variables are prefixed `BEEFY_`: `BEEFY_IMAGES` (`tgp`, `iterm2`
or `0`), `BEEFY_MPV_ARGS` and `BEEFY_SPECTRUM_DEVICE`.
## Desktop media widgets (MPRIS / Now Playing)
### Keys
**Linux** — while playing, the app exports
`org.mpris.MediaPlayer2.beefyplayer.instance<pid>` on the **session bus**. Check
it:
| key | action |
| --- | --- |
| `↑` / `↓`, `j` / `k` | move through stations |
| `pgup` / `pgdn` | page through stations |
| `enter` | tune in to the selected station |
| `space` | play / pause |
| `s` | stop |
| `]` / `[` | next / previous station (tunes immediately) |
| `+` / `-` | volume |
| `m` | mute |
| `f` | favourite (favourites sort to the top) |
| `/` | filter by title, genre, description |
| `esc` | close the search box; press again to clear the filter |
| `v` | visualiser: mirror → bars → wave → waterfall → radial → braille |
| `c` | colour palette (16 of them, remembered) |
| `r` | refresh stations, schedule and song history |
| `p` | switch station catalogue |
| `g` / `G` | top / bottom |
| `?` | help |
| `q` | quit |
## Station catalogues
| catalogue | slug | stations |
| --- | --- | --- |
| [SomaFM](https://somafm.com) | `somafm` | ≈50 curated channels |
| [Radio Paradise](https://radioparadise.com) | `radioparadise` | 5 mixes |
| [tilderadio](https://tilderadio.org) | `tilderadio` | 1 + schedule |
| [Bassdrive](https://bassdrive.com) | `bassdrive` | 1 + schedule |
| [dublab](https://dublab.com) | `dublab` | 1 + schedule |
| [Rinse FM](https://rinse.fm) | `rinse` | 3 |
| [Refuge Worldwide](https://refugeworldwide.com) | `refuge` | 1 |
| [LYL Radio](https://lyl.live) | `lyl` | 1 |
| [The Lot Radio](https://thelotradio.com) | `lot` | 1 |
| [Cashmere Radio](https://cashmereradio.com) | `cashmere` | 1 |
| [Datafruits](https://datafruits.fm) | `datafruits` | 1 |
| [aNONradio](https://anonradio.net) | `anonradio` | 1 |
| [Radio Browser](https://www.radio-browser.info) | `radiobrowser` | 62k · paged + live search |
Pick one with `p` in the UI, `--provider <slug>` on the command line, or by
setting `"provider"` in the config file.
## Desktop media integration
**Linux** — while playing, the app exposes an
`org.mpris.MediaPlayer2.beefyplayer.instance<pid>` player on the session bus,
so GNOME/KDE widgets, lock screens, `playerctl` and the media keys pick it up:
```bash
playerctl -l
@@ -263,178 +212,64 @@ playerctl metadata --format '{{artist}} — {{title}} ({{album}})'
playerctl play-pause
```
`mpris:artUrl` is the track's album art when the provider supplies it,
otherwise the channel logo. If there is no session bus (SSH without
`dbus-run-session`, containers, …) MPRIS is skipped and the status bar shows
`mpris off`.
If there is no session bus (SSH without `dbus-run-session`, containers, …)
MPRIS is skipped and the status bar shows `mpris off`.
**macOS** — mpv owns the system Now Playing session, so Control Center, the
menu-bar widget and the keyboard media keys (▶︎/⏸, ⏭, ⏮) all target this app
while it is playing. mpv publishes the live stream title (the same
“Artist - Title” that the station sends with the audio), and the media keys are
routed back into the TUI, so `⏭`/`⏮` really change station and the panel, the
spectrum and the station list stay in sync. The status bar shows
`nowplaying on`. There is no D-Bus and no extra dependency involved, and
`--no-mpris` (also spelled `--no-media`) turns the media keys off again.
**macOS** — mpv publishes to the system Now Playing session, so Control Center,
the menu bar and the media keys drive the app while it plays. The status bar
shows `nowplaying on`. `--no-mpris` (also spelled `--no-media`) turns the media
keys off.
> macOS shows one Now Playing app at a time. If Music/Safari/another mpv is
> playing, the media keys go there instead — quit it or pause it to hand the
> keys back.
> macOS shows one Now Playing app at a time. If Music, Safari or another mpv is
> playing, the media keys go there instead.
## How it works
## Configuration
```
beefy_player/
app.py Textual app: layout, keys, orchestration
models.py Song + Station, provider-neutral
config.py XDG paths, settings, BEEFY_* env helpers
providers/
base.py RadioProvider interface + HTTP/playlist toolkit
somafm/ the SomaFM catalogue (API, bundled fallback list)
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
```
Settings live in `~/.config/beefy-player/config.json` (favourites, volume,
provider and the last station tuned in each catalogue). Cached stations,
schedules and artwork live in `~/.cache/beefy-player/`.
- `beefy_player/providers/base.py` — the provider interface and the shared
toolkit: `httpx` client factory plus `.pls`/`.m3u` parsing and resolution
(cached for 8 hours), so providers only describe their own catalogue.
- `beefy_player/providers/somafm/api.py` — async `httpx` client for:
- `channels.json` → stations, genres, listeners, `.pls` playlist URLs
- `<station>.pls` → resolved direct icecast URLs (cached 8h)
- `songs/<station>.json` → last played tracks (cached on disk)
Playlist and channel caching means the UI opens instantly and still works
offline.
- `beefy_player/app.py` — holds one provider and one `Player`, ticks at 20 Hz,
and hands the widgets plain `Station`/`Song` objects.
- `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.
These stay in the usual XDG locations on every platform, macOS included;
`XDG_CONFIG_HOME` and `XDG_CACHE_HOME` are honoured when set.
### Files
## Troubleshooting
```
~/.config/beefy-player/config.json favourites, volume, last station, mode
~/.cache/beefy-player/channels.json last good station list
~/.cache/beefy-player/playlists.json resolved stream URLs
~/.cache/beefy-player/songs/<id>.json song history snapshots
~/.cache/beefy-player/artwork/<sha1> cached album/channel artwork
```
These stay in the XDG locations on every platform (macOS included), so the
same dotfiles/config work everywhere; `XDG_CONFIG_HOME`, `XDG_CACHE_HOME` and
`XDG_RUNTIME_DIR` are honoured when set. The app started life as
`somafm-tui`: on first run a `~/.config/somafm-tui/config.json` is copied over
so favourites and volume survive the rename.
## Notes / troubleshooting
- **Artwork shows the mosaic?** Real images are auto-enabled for Kitty,
iTerm2 and WezTerm. Force a protocol with `BEEFY_IMAGES=tgp` (Kitty/TGP) or
`BEEFY_IMAGES=iterm2` (iTerm2/WezTerm); `BEEFY_IMAGES=0` forces the ANSI
mosaic. iTerm2 on macOS is detected from `TERM_PROGRAM`/`LC_TERMINAL`, and
empty or `0` values in `TMUX`/`ZELLIJ`/`STY` (exported by some wrappers) are
ignored so they cannot downgrade it. Kitty inside tmux is attempted through
tmux passthrough (enable `set -g allow-passthrough on`); iTerm2/WezTerm
inside tmux default to the mosaic. Images are cached under
`~/.cache/beefy-player/artwork`.
- **Clock shows UTC?** The clock and song history use the system zone from
`/etc/localtime`; a non-UTC `TZ` overrides it. A container or dev shell can
inherit a build-time `TZ=UTC`, which the app deliberately ignores; bind the
host's `/etc/localtime` or set e.g. `TZ=Europe/Berlin` in the shell that
launches the app.
- **MPRIS says off/error?** No session bus. Run from your desktop session
(the shell profile picks up `$XDG_RUNTIME_DIR/bus`). `dbus-run-session -- uv
run beefy-player` starts a private bus for testing, but desktop widgets won't
see it. macOS does not use MPRIS at all — see “Now Playing” above.
- **No audio?** `beefy-player` needs `mpv` (preferred) or `ffplay`. Without
either, the UI still runs and tells you. Extra mpv args can be injected with
`BEEFY_MPV_ARGS` (e.g. `BEEFY_MPV_ARGS='--audio-device=...'`, which is the
macOS way to choose an output device).
- **Spectrum says `FFT` instead of `SYNC`?** The monitor is unavailable or
failed, or you forced the stream tap. The bars are still real audio, but the
second stream can drift relative to playback. Use `--spectrum-source
monitor` to force sync, or `BEEFY_SPECTRUM_DEVICE=<source>` on Linux to pick
a specific monitor. `SIM` means the bars are synthesized.
- **Diagnose artwork:** `uv run beefy-player --diagnose-images` prints the
detected protocol, widget class, terminal env, dependency versions and cache
path. If `protocol` is `None`, force it with `BEEFY_IMAGES=tgp` (Kitty) or
`BEEFY_IMAGES=iterm2` (iTerm2/WezTerm).
- **`SYNC` but the bars are flat?** mpv may be playing to a different sink (or
directly to ALSA). Point `BEEFY_SPECTRUM_DEVICE` at that sink's monitor, or
use `--spectrum-source stream`.
- **Artwork shows the mosaic?** Real images are auto-enabled for Kitty, iTerm2
and WezTerm. Force a protocol with `BEEFY_IMAGES=tgp` (Kitty) or
`BEEFY_IMAGES=iterm2` (iTerm2/WezTerm); `BEEFY_IMAGES=0` forces the mosaic.
`--diagnose-images` prints what was detected.
- **No audio?** `mpv` is preferred, `ffplay` works as a fallback. Without
either, the UI still runs and tells you. Extra mpv arguments can be injected
with `BEEFY_MPV_ARGS`, e.g. `BEEFY_MPV_ARGS='--audio-device=...'`.
- **Spectrum says `FFT` instead of `SYNC`?** Output capture is unavailable or
failed, so the analyser is decoding the stream instead; the bars are still
real audio but can drift. Force sync with `--spectrum-source monitor`, or
pick a specific monitor with `BEEFY_SPECTRUM_DEVICE` on Linux. `SIM` means the
bars are synthesized.
- **macOS: spectrum stays `FFT`?** Grant your terminal **System Audio
Recording** permission (*System Settings → Privacy & Security → System Audio
Recording*), then restart the app; the status bar explains when the tap is
silent because of this. Also check that mpv is playing to the default output
device, since that is what the tap records.
- **macOS: media keys do nothing?** Another app currently owns the Now Playing
session, or the app was started with `--no-mpris`, or the audio backend fell
back to `ffplay` (mpv is required for Now Playing). The status bar shows
`nowplaying on` when the integration is armed.
- **macOS: Control Center shows only a title?** mpv publishes the stream's live
“Artist - Title” as the Now Playing title; artist/album fields and artwork are
not filled in by macOS for plain audio streams. The TUI itself always shows
the full metadata and album art.
- **Bandwidth** is unchanged in SYNC mode (the monitor capture creates no
second stream). The FFT fallback opens one extra stream; if you are on a
metered link, use `--spectrum-source monitor` (or `--no-audio` for a UI-only
demo).
- SomaFM is listener-supported: if you enjoy it, [donate](https://somafm.com/support/).
Recording** permission under *System Settings → Privacy & Security → System
Audio Recording*, then restart the app. Also check that mpv is playing to the
default output device, since that is what the analyser records.
- **macOS: media keys do nothing?** Another app owns the Now Playing session,
the app was started with `--no-mpris`, or audio fell back to `ffplay` (mpv is
required for Now Playing).
- **MPRIS says off?** There is no session bus. Run from your desktop session,
or use `dbus-run-session` for a private bus. macOS does not use MPRIS.
## NixOS (optional aside)
None of the above needs Nix. If you *are* on NixOS, `shell.nix` wraps the same
`uv sync` / `uv run` workflow in an FHS environment, because uv installs
manylinux wheels such as NumPy that expect `libstdc++.so.6` in the usual
`/usr/lib` paths:
## Development
```bash
nix-shell # enter the FHS dev shell
uv sync
uv run beefy-player
uv sync # create the environment
uv run pytest # test suite
uvx ruff check . # lint
```
- If you see `libstdc++.so.6: cannot open shared object file`, make sure you
are inside `nix-shell`, then recreate the venv once with
`rm -rf .venv && uv sync`.
- `nix-shell --run "..."` does not work with FHS `shell.nix` files; enter the
shell interactively and run commands there.
- FHS shells need unprivileged user namespaces. `bwrap: No permissions to
create a new namespace` means they are disabled (for example in a locked-down
container); run the app on the host instead.
Each catalogue is a self-contained provider module under
`beefy_player/providers/`; `base.py` documents the interface. `tools/ansi_art.py`
converts a PNG logo into half-block ANSI art. On NixOS, `nix develop` (or the
older `shell.nix`) provides the same `uv sync` / `uv run` workflow.
## Licence
WTFPL — Do What The Fuck You Want To Public License, Version 2. See
[LICENSE](LICENSE). SomaFM and its channel names are trademarks of
SomaFM.com, LLC.
Terminal image rendering uses [Pillow](https://python-pillow.org/).
WTFPL — Do What The Fuck You Want To Public License, Version 2.
See [LICENSE](LICENSE).
+28
View File
@@ -0,0 +1,28 @@
pkgbase = beefy-player
pkgdesc = Flashy terminal player for internet radio with a live spectrum analyser
pkgver = 1.2.2
pkgrel = 1
url = https://tildeforge.dev/beefcarver/beefy-player
arch = any
license = WTFPL
depends = python
depends = python-dbus-next
depends = python-httpx
depends = python-numpy
depends = python-pillow
depends = python-rich
depends = python-textual
makedepends = python-build
makedepends = python-hatchling
makedepends = python-installer
makedepends = python-wheel
checkdepends = python-pytest
checkdepends = python-pytest-asyncio
optdepends = ffmpeg: decode the stream for the spectrum analyser, and ffplay as the audio fallback
optdepends = libpulse: capture the output monitor so the analyser follows what you hear (parec/pactl)
optdepends = mpv: preferred audio backend
optdepends = tmux: detect the outer terminal for kitty graphics passthrough inside tmux
source = beefy-player-1.2.2.tar.gz::https://tildeforge.dev/beefcarver/beefy-player/archive/941c7446384bf346e40d1438a7ce67939d9615df.tar.gz
sha256sums = 8ac568676ef5ef8345441c28e4f8f5d61fb06d5225e566ac578acae18672c778
pkgname = beefy-player
+59
View File
@@ -0,0 +1,59 @@
# Maintainer: beefy-player contributors <beefcarver@tilde.club>
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://aur@aur.archlinux.org/beefy-player.git /tmp/aur-beefy-player
cp PKGBUILD .SRCINFO /tmp/aur-beefy-player/
cd /tmp/aur-beefy-player
git add PKGBUILD .SRCINFO
git commit -m "Initial import: beefy-player 1.2.2-1"
git push
```
## Releasing a new version
1. Set `pkgver` in `PKGBUILD` and `pyproject.toml`, and reset `pkgrel=1`.
2. Point `_commit` at the commit carrying that version:
```bash
git rev-parse HEAD # or the tag, once one exists
```
3. Refresh the checksum:
```bash
curl -fsSL "https://tildeforge.dev/beefcarver/beefy-player/archive/$_commit.tar.gz" | sha256sum
```
4. Regenerate the metadata (on Arch, or inside a chroot):
```bash
makepkg --printsrcinfo > .SRCINFO
```
5. Push to the AUR with the conventional commit message:
```bash
git commit -am "upgpkg: beefy-player <pkgver>-<pkgrel>"
```
## Why the source is a commit archive
Upstream publishes no tags or releases, so the checksum in `sha256sums`
belongs to the archive of a specific commit rather than of a `v$pkgver` tag.
Forge archives of a commit are deterministic, so the checksum stays stable.
Once `v$pkgver` is tagged, replace the `source` line with
```bash
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
```
## Packaging notes
- The wheel is built with `hatchling`; `theme.tcss` is force-included by
`pyproject.toml`, so it ends up in `site-packages/beefy_player/`.
- `check()` runs the real test suite. It is offline by design (station
catalogues are fixtures and `mpv`/`parec` are faked), which makes it safe in
a build chroot with no network.
- `mpv`, `ffmpeg`, `libpulse` (`parec`/`pactl`) and `tmux` are `optdepends`,
not `depends`: the player degrades gracefully without them and says so in
the UI. Users who want sound need `mpv` (or `ffmpeg` for `ffplay`) and, for
the output-synced spectrum, `libpulse`.
- `license=('WTFPL')` is an SPDX identifier shipped by Arch's `licenses`
package.
+1 -1
View File
@@ -1,3 +1,3 @@
"""Beefy Player — a flashy terminal player for internet radio."""
__version__ = "0.1.0"
__version__ = "1.2.2"
+577 -14
View File
@@ -25,13 +25,17 @@ from textual.widgets import Input
from . import artwork, bandcamp, config
from .mediasession import create_media_session, media_actions_queue
from .models import Station, parse_last_playing
from .models import Broadcast, Station, parse_last_playing
from .palettes import PALETTE_ORDER
from .player import Player
from .providers import get_provider
from .providers.base import is_direct_stream, make_client
from .spectrum import SpectrumEngine
from .widgets.art import ArtPane
from .widgets.header import HeaderBar
from .widgets.help import HelpScreen
from .widgets.providers import ProviderScreen
from .widgets.schedule import SchedulePanel
from .widgets.search import SearchInput
from .widgets.spectrum import SpectrumPanel
from .widgets.stage import StagePanel
@@ -40,6 +44,10 @@ from .widgets.status import StatusBar
VOLUME_STEP = 5
#: How close the cursor may get to the end of a paginated list before the next
#: directory page is fetched.
LOAD_MORE_MARGIN = 20
# A theme made only of terminal defaults / ANSI palette entries. Textual's
# built-in themes paint an RGB background and map ANSI names to their own RGB
# palette, which breaks transparency and ignores the user's colours.
@@ -74,6 +82,8 @@ class BeefyPlayerApp(App[None]):
Binding("down,j", "cursor_down", "down", show=False),
Binding("g", "cursor_top", "top", show=False),
Binding("shift+g", "cursor_bottom", "bottom", show=False),
Binding("pageup", "page_up", "page up", show=False),
Binding("pagedown", "page_down", "page down", show=False),
Binding("enter", "tune", "tune in"),
Binding("space", "toggle_play", "play/pause"),
Binding("s", "stop", "stop", show=False),
@@ -88,7 +98,9 @@ class BeefyPlayerApp(App[None]):
Binding("slash", "search", "filter"),
Binding("escape", "escape", "close", show=False),
Binding("v", "visualizer", "visualiser"),
Binding("c", "palette", "palette"),
Binding("r", "refresh", "refresh"),
Binding("p", "provider", "catalogue"),
Binding("question_mark", "help", "help"),
Binding("q", "quit", "quit"),
]
@@ -125,18 +137,39 @@ class BeefyPlayerApp(App[None]):
if volume is not None:
self.config.volume = max(0, min(100, int(volume)))
self.start_station_id = station_id or self.config.last_station
self.start_station_id = station_id or self.config.last_station_for(
self.provider.id
)
self.offline = offline
self.audio_enabled = audio
self.stations: list[Station] = []
self.view: list[Station] = []
#: The current catalogue's broadcast schedule, when it has one.
self.schedule: list[Broadcast] = []
self.schedule_loaded = False
self.index = 0
self.query = ""
#: Server-side directory results for ``query``; ``None`` means the
#: view is filtered locally from :attr:`stations`.
self.search_results: list[Station] | None = None
self._search_results_query = ""
#: A server-side search is in flight; the list shows a searching hint.
self.search_pending = False
#: Stations reached through a search or a later directory page. They
#: are kept when a refresh only fetches the first page again.
self._discovered_station_ids: set[str] = set()
#: Directory paging: next offset, whether there are more pages, and
#: whether a page request is already in flight.
self._directory_offset = 0
self._directory_exhausted = True
self._loading_more = False
self.current: Station | None = None
self.playing_state = "stopped"
self.volume = self.config.volume
self.visualizer = self.config.visualizer
#: Colour palette name (see :mod:`beefy_player.palettes`).
self.palette = self.config.palette
self.tick_count = 0
self.notice = ""
@@ -144,6 +177,8 @@ class BeefyPlayerApp(App[None]):
self._volume_before_mute = self.volume or 70
self._last_history_fetch = 0.0
self._history_refresh_seconds = 25.0
self._schedule_refresh_seconds = 300.0
self._last_schedule_fetch = 0.0
self._stream_url = ""
self.artwork_url = ""
self.artwork_bytes: bytes | None = None
@@ -174,6 +209,7 @@ class BeefyPlayerApp(App[None]):
with Vertical(id="left"):
yield SearchInput()
yield StationList()
yield SchedulePanel()
yield StagePanel()
yield SpectrumPanel(self.spectrum)
yield StatusBar()
@@ -188,11 +224,14 @@ class BeefyPlayerApp(App[None]):
self.media.start()
self._http = make_client(self.provider.user_agent)
self._apply_catalogue_chrome()
stations = self.provider.load_cached_stations()
cached = bool(stations)
if not stations:
stations = self.provider.fallback_stations()
self._set_stations(stations)
self._reset_directory_paging(stations, has_more=cached)
self._select_initial_station()
if self.current is not None:
@@ -208,6 +247,9 @@ class BeefyPlayerApp(App[None]):
self._refresh_stations()
if self.current is not None:
self._fetch_history(self.current.id)
if self.provider.has_schedule:
# Offline still shows the cached schedule — the worker decides.
self._refresh_schedule()
self.set_focus(None)
@@ -233,13 +275,39 @@ class BeefyPlayerApp(App[None]):
self.config.volume = self.volume
if self.current is not None:
self.config.last_station = self.current.id
self.config.last_stations[self.provider.id] = self.current.id
self.config.visualizer = self.visualizer
self.config.save()
def _find_station(self, station_id: str) -> Station | None:
"""Find a station by id: on air, in the catalogue, or in the search."""
if self.current is not None and self.current.id == station_id:
return self.current
for station in self.stations:
if station.id == station_id:
return station
for station in self.search_results or ():
if station.id == station_id:
return station
return None
def _set_stations(self, stations: list[Station]) -> None:
if not stations:
return
stations = list(stations)
known = {station.id for station in stations}
for station in self.stations:
if (
station.id in self._discovered_station_ids
and station.id not in known
):
# Keep stations found through search or a later page when a
# refresh only knows the first page.
stations.append(station)
known.add(station.id)
existing = {station.id: station for station in self.stations}
if self.current is not None:
existing[self.current.id] = self.current
for station in stations:
previous = existing.get(station.id)
if previous is not None:
@@ -261,6 +329,33 @@ class BeefyPlayerApp(App[None]):
self._update_artwork()
self.query_one(HeaderBar).refresh()
def _reset_directory_paging(
self, stations: list[Station], *, has_more: bool = True
) -> None:
self._loading_more = False
if not self.provider.paginated or not has_more:
self._directory_offset = 0
self._directory_exhausted = True
return
# The cached list is in catalogue order, so continue after it.
self._directory_offset = len(stations)
self._directory_exhausted = False
def _append_stations(self, stations: list[Station]) -> int:
"""Add a page to the catalogue, preserving existing station state."""
known = {station.id for station in self.stations}
added = 0
for station in stations:
if station.id in known:
continue
station.favorite = self.config.is_favorite(station.id)
if station.now_playing is None and station.last_playing:
station.now_playing = parse_last_playing(station.last_playing)
self.stations.append(station)
known.add(station.id)
added += 1
return added
def _select_initial_station(self) -> None:
if not self.view:
return
@@ -284,7 +379,8 @@ class BeefyPlayerApp(App[None]):
break
def _rebuild_view(self, keep_id: str | None = None) -> None:
terms = self.query.casefold().split()
query = self.query.strip()
terms = query.casefold().split()
def matches(station: Station) -> bool:
if not terms:
@@ -298,8 +394,27 @@ class BeefyPlayerApp(App[None]):
if keep_id is None and self.view and 0 <= self.index < len(self.view):
keep_id = self.view[self.index].id
view = [station for station in self.stations if matches(station)]
view.sort(key=lambda station: (0 if station.favorite else 1, station.title.casefold()))
if (
terms
and self.search_results is not None
and self._search_results_query == query
):
# The directory matched fields that are not all part of a Station's
# local search text; trust its answer instead of filtering again.
view = list(self.search_results)
else:
view = [station for station in self.stations if matches(station)]
if self.provider.preserve_order:
# Stable sort: keep the catalogue's popularity order within the
# favourites and everything else.
view.sort(key=lambda station: 0 if station.favorite else 1)
else:
view.sort(
key=lambda station: (
0 if station.favorite else 1,
station.title.casefold(),
)
)
self.view = view
if keep_id is not None:
@@ -315,6 +430,82 @@ class BeefyPlayerApp(App[None]):
if self.current is None and view:
self.current = view[self.index]
def _set_search_results(self, query: str, stations: list[Station]) -> None:
"""Show a server-side directory search in place of the loaded list."""
known = {station.id: station for station in self.stations}
if self.current is not None:
known[self.current.id] = self.current
results: list[Station] = []
for station in stations:
previous = known.get(station.id)
if previous is not None:
# Reuse the catalogue's object so favourite/history changes
# made in the search results show up in the regular list too.
station = previous
else:
station.favorite = self.config.is_favorite(station.id)
if station.now_playing is None and station.last_playing:
station.now_playing = parse_last_playing(station.last_playing)
results.append(station)
self.search_results = results
self._search_results_query = query
self.search_pending = False
self._rebuild_view(
keep_id=self.current.id if self.current is not None else None
)
if self.current is None or not any(
station.id == self.current.id for station in self.view
):
self.index = 0
with contextlib.suppress(NoMatches):
pane = self.query_one(StationList)
pane.set_filter(self.query)
pane.refresh()
# ------------------------------------------------------------------ #
# the catalogue's chrome
# ------------------------------------------------------------------ #
def _apply_catalogue_chrome(self) -> None:
"""Point the chrome at the catalogue: hero height and the left pane."""
provider = self.provider
hero_rows = len(getattr(provider, "hero", ()) or ())
# Give the hero its rows, but never more than a quarter of the screen:
# on a short terminal the spectrum and the stage matter more.
screen_height = getattr(self, "size", None)
cap = max(4, (screen_height.height // 4) if screen_height else 10)
with contextlib.suppress(NoMatches):
header = self.query_one(HeaderBar)
header.styles.height = min(hero_rows + 1, cap) if hero_rows else 4
header.refresh()
self.query_one(StationList).display = not provider.has_schedule
self.query_one(SchedulePanel).display = provider.has_schedule
if provider.has_schedule:
self._reset_filter()
def _reset_filter(self) -> None:
"""Forget the station filter and put the search box away."""
self.query = ""
self.search_results = None
self._search_results_query = ""
self.search_pending = False
search = self.query_one(SearchInput)
search.value = ""
search.remove_class("visible")
search.disabled = True
self.query_one(StationList).set_filter("")
def on_resize(self, event) -> None:
self._apply_catalogue_chrome()
def _refresh_pane(self) -> None:
with contextlib.suppress(NoMatches):
if self.provider.has_schedule:
self.query_one(SchedulePanel).refresh()
else:
self.query_one(StationList).refresh()
# ------------------------------------------------------------------ #
# playback
# ------------------------------------------------------------------ #
@@ -322,6 +513,12 @@ class BeefyPlayerApp(App[None]):
def _tune(self, station: Station) -> None:
self.current = station
self.start_station_id = station.id
if not any(existing.id == station.id for existing in self.stations):
# A directory search can reach a station outside the loaded pages;
# keep it in the catalogue so clearing the filter still shows it.
self.stations.append(station)
# Remember it so a refresh that only fetches the first page keeps it.
self._discovered_station_ids.add(station.id)
self.playing_state = "loading"
self._set_notice(f"tuning in to {station.title}…", 5)
self._save_config()
@@ -342,7 +539,7 @@ class BeefyPlayerApp(App[None]):
@work(exclusive=True, group="stream", exit_on_error=False)
async def _resolve_and_play(self, station_id: str) -> None:
station = next((s for s in self.stations if s.id == station_id), None)
station = self._find_station(station_id)
if station is None or self.current is None or self.current.id != station_id:
return
@@ -377,7 +574,7 @@ class BeefyPlayerApp(App[None]):
@work(exclusive=True, group="history", exit_on_error=False)
async def _fetch_history(self, station_id: str) -> None:
self._last_history_fetch = time.monotonic()
station = next((s for s in self.stations if s.id == station_id), None)
station = self._find_station(station_id)
if station is None or self._http is None:
return
try:
@@ -455,6 +652,11 @@ class BeefyPlayerApp(App[None]):
song = station.now_playing
if song is None or not (song.artist.strip() or song.title.strip()):
return
if not getattr(self.provider, "bandcamp", True):
# Not a Bandcamp catalogue: never look the track up, and do not
# leave a stale link behind from the previous station.
song.bandcamp_url = ""
return
track_key = f"{station.id}|{song.key()}"
cached_url = self._bandcamp_urls.get(track_key)
@@ -491,7 +693,7 @@ class BeefyPlayerApp(App[None]):
return
self._bandcamp_urls[f"{station_id}|{song_key}"] = url
station = next((s for s in self.stations if s.id == station_id), None)
station = self._find_station(station_id)
if station is None or self.current is None or self.current.id != station_id:
return
song = station.now_playing
@@ -615,8 +817,9 @@ class BeefyPlayerApp(App[None]):
self.query_one(HeaderBar).refresh()
if self.tick_count % 12 == 0:
self.query_one(StatusBar).refresh()
self.query_one(StationList).refresh()
self._refresh_pane()
self._maybe_refresh_history()
self._maybe_refresh_schedule()
def _drain_player(self) -> None:
while True:
@@ -693,29 +896,210 @@ class BeefyPlayerApp(App[None]):
continue
self._set_volume(value)
def _maybe_refresh_schedule(self) -> None:
if not self.provider.has_schedule or self.offline:
return
if (
time.monotonic() - self._last_schedule_fetch
>= self._schedule_refresh_seconds
):
self._refresh_schedule()
@work(exclusive=True, group="schedule", exit_on_error=False)
async def _refresh_schedule(self) -> None:
if not self.provider.has_schedule:
return
self._last_schedule_fetch = time.monotonic()
broadcasts: list[Broadcast] = []
if self.offline or self._http is None:
broadcasts = self.provider.load_cached_schedule()
else:
try:
broadcasts = await self.provider.fetch_schedule(self._http)
except Exception:
broadcasts = self.provider.load_cached_schedule()
self.schedule = broadcasts
self.schedule_loaded = True
with contextlib.suppress(NoMatches):
self.query_one(SchedulePanel).refresh()
# ------------------------------------------------------------------ #
# workers
# ------------------------------------------------------------------ #
@work(exclusive=True, group="stations-page", exit_on_error=False)
async def _load_more_stations(self) -> None:
try:
if (
not self.provider.paginated
or self._directory_exhausted
or self.offline
or self._http is None
):
return
page = await self.provider.fetch_station_page(
self._http, self._directory_offset
)
except Exception:
self._set_notice("could not load more stations", 4)
return
finally:
self._loading_more = False
self._directory_offset = page.next_offset or 0
self._directory_exhausted = page.next_offset is None
if not self._append_stations(page.stations):
return
keep_id = (
self.view[self.index].id
if self.view and 0 <= self.index < len(self.view)
else None
)
self._rebuild_view(keep_id=keep_id)
with contextlib.suppress(NoMatches):
pane = self.query_one(StationList)
pane.set_filter(self.query)
pane.refresh()
def _maybe_load_more_stations(self) -> None:
"""Queue the next directory page when the cursor nears the end."""
if (
not self.provider.paginated
or self._loading_more
or self._directory_exhausted
or self.offline
or not self.view
or self.query.strip()
):
return
if self.index < len(self.view) - LOAD_MORE_MARGIN:
return
self._loading_more = True
self._set_notice("loading more stations…", 3)
self._load_more_stations()
@work(exclusive=True, group="search", exit_on_error=False)
async def _search_directory(self, query: str) -> None:
"""Debounced server-side search, for catalogues that support it."""
text = query.strip()
if (
not text
or not self.provider.server_search
or self.offline
or self._http is None
):
self.search_pending = False
return
# A new keystroke starts a new exclusive worker and cancels this sleep.
await asyncio.sleep(0.25)
if text != self.query.strip():
return
try:
stations = await self.provider.search_stations(self._http, text)
except Exception:
if text == self.query.strip():
self.search_pending = False
self._set_notice(
"directory search failed — filtering the loaded stations", 4
)
self._refresh_pane()
return
if text != self.query.strip():
return
self._set_search_results(text, stations)
async def _load_favorite_stations(self) -> None:
"""Fetch favourites that are not on the currently loaded page."""
if self.offline or self._http is None or not self.provider.paginated:
return
known = {station.id for station in self.stations}
missing = [
station_id
for station_id in self.config.favorites
if station_id not in known
]
if not missing:
return
try:
stations = await self.provider.fetch_stations_by_ids(
self._http, missing
)
except Exception:
return
added = 0
for station in stations:
if any(existing.id == station.id for existing in self.stations):
continue
if not self.config.is_favorite(station.id):
continue
previous = next(
(
searched
for searched in (self.search_results or ())
if searched.id == station.id
),
None,
)
if previous is not None:
# A search result and a favourite lookup can race; keep one
# object so favourite/history changes are shared.
station = previous
station.favorite = True
self.stations.append(station)
self._discovered_station_ids.add(station.id)
added += 1
if not added:
return
keep_id = (
self.view[self.index].id
if self.view and 0 <= self.index < len(self.view)
else None
)
self._rebuild_view(keep_id=keep_id)
with contextlib.suppress(NoMatches):
pane = self.query_one(StationList)
pane.set_filter(self.query)
pane.refresh()
@work(exclusive=True, group="stations", exit_on_error=False)
async def _refresh_stations(self) -> None:
if self.offline or self._http is None:
return
try:
stations = await self.provider.fetch_stations(self._http)
if self.provider.paginated:
page = await self.provider.fetch_station_page(self._http, 0)
stations = page.stations
next_offset = page.next_offset
else:
stations = await self.provider.fetch_stations(self._http)
next_offset = None
if not stations:
raise RuntimeError("the catalogue returned no stations")
except Exception:
if not self.stations:
self._set_stations(self.provider.fallback_stations())
self._set_notice("offline — using the built-in station list", 8)
else:
self._set_notice("station refresh failed — using the cached list", 6)
# The full directory may be unreachable while a favourite lookup
# still works; show those rather than hiding them until a restart.
await self._load_favorite_stations()
return
before = self.current.id if self.current is not None else None
self._set_stations(stations)
if before is not None:
self._select_station_id(before)
self._set_notice(f"{len(stations)} stations", 3)
self._directory_offset = next_offset or 0
self._directory_exhausted = next_offset is None
self._loading_more = False
self._set_notice(
"1 station" if len(stations) == 1 else f"{len(stations)} stations", 3
)
self.query_one(StationList).refresh()
await self._load_favorite_stations()
# ------------------------------------------------------------------ #
# actions
@@ -732,6 +1116,21 @@ class BeefyPlayerApp(App[None]):
return
self.index = min(len(self.view) - 1, self.index + 1)
self.query_one(StationList).refresh()
self._maybe_load_more_stations()
def _page(self, delta: int) -> None:
if not self.view:
return
page = max(1, self.query_one(StationList).size.height - 1)
self.index = max(0, min(len(self.view) - 1, self.index + delta * page))
self.query_one(StationList).refresh()
self._maybe_load_more_stations()
def action_page_up(self) -> None:
self._page(-1)
def action_page_down(self) -> None:
self._page(1)
def action_cursor_top(self) -> None:
if not self.view:
@@ -744,6 +1143,7 @@ class BeefyPlayerApp(App[None]):
return
self.index = len(self.view) - 1
self.query_one(StationList).refresh()
self._maybe_load_more_stations()
def action_tune(self) -> None:
if not self.view:
@@ -803,6 +1203,21 @@ class BeefyPlayerApp(App[None]):
return
added = self.config.toggle_favorite(station.id)
station.favorite = added
known = False
for existing in self.stations:
if existing.id == station.id:
# A search copy and the catalogue copy can briefly coexist;
# keep both in step so clearing the filter cannot resurrect
# the old favourite state.
existing.favorite = added
known = True
break
if added:
# A favourite reached through a directory search must join the
# catalogue, or clearing the filter would hide it again.
self._discovered_station_ids.add(station.id)
if not known:
self.stations.append(station)
self._save_config()
self._rebuild_view(keep_id=station.id)
self._set_notice(("★ added " if added else "☆ removed ") + station.title, 3)
@@ -810,6 +1225,9 @@ class BeefyPlayerApp(App[None]):
self.query_one(StagePanel).refresh_stage()
def action_search(self) -> None:
if self.provider.has_schedule:
self._set_notice("this catalogue shows a schedule, not a station list", 3)
return
search = self.query_one(SearchInput)
search.disabled = False
search.add_class("visible")
@@ -818,9 +1236,12 @@ class BeefyPlayerApp(App[None]):
search.cursor_position = len(search.value)
def action_escape(self) -> None:
if self.query:
search = self.query_one(SearchInput)
if self.query and not search.has_class("visible"):
# The filter is already sticky: a second escape clears it.
self._close_search(clear=True)
else:
# Put the search box away but keep the filter on the station list.
self._close_search(clear=False)
def _close_search(self, clear: bool = False) -> None:
@@ -828,6 +1249,9 @@ class BeefyPlayerApp(App[None]):
search.remove_class("visible")
if clear:
self.query = ""
self.search_results = None
self._search_results_query = ""
self.search_pending = False
search.value = ""
search.disabled = True
self.set_focus(None)
@@ -835,6 +1259,7 @@ class BeefyPlayerApp(App[None]):
self.query_one(StationList).set_filter(self.query)
self.query_one(StationList).refresh()
self.query_one(StagePanel).refresh_stage()
self._maybe_load_more_stations()
def action_visualizer(self) -> None:
modes = list(config.VISUALIZERS)
@@ -847,22 +1272,148 @@ class BeefyPlayerApp(App[None]):
self._set_notice(f"visualiser: {self.visualizer}", 2)
self.query_one(SpectrumPanel).refresh()
def action_palette(self) -> None:
order = list(PALETTE_ORDER)
index = order.index(self.palette) if self.palette in order else 0
self.palette = order[(index + 1) % len(order)]
self.config.palette = self.palette
self._save_config()
self._set_notice(f"palette: {self.palette}", 2)
# Frames, spectrum, mosaic and wordmarks all read the palette live, so
# a repaint of every widget is all it takes. (``self.query`` is the
# station filter here, hence ``self.screen.query``.)
for widget in self.screen.query("*"):
with contextlib.suppress(Exception):
widget.refresh()
def action_refresh(self) -> None:
self._last_history_fetch = 0.0
self._bandcamp_key = ""
self._bandcamp_urls.clear()
with contextlib.suppress(Exception):
self.workers.cancel_group(self, "stations-page")
self._loading_more = False
self._set_notice("refreshing…", 2)
if self.offline:
self._set_notice("offline mode — nothing to refresh", 3)
return
self.artwork_url = ""
self._refresh_stations()
self._refresh_schedule()
if self.current is not None:
self._update_artwork()
self._fetch_history(self.current.id)
def action_provider(self) -> None:
self._suspend_artwork()
self.push_screen(ProviderScreen(self.provider.id), self._provider_chosen)
def _provider_chosen(self, provider_id: str | None) -> None:
self._resume_artwork()
if provider_id:
self._switch_catalogue(provider_id)
def _suspend_artwork(self) -> None:
"""Hide the inline artwork: terminal images would sit over a menu."""
with contextlib.suppress(NoMatches):
self.query_one(ArtPane).suspend()
def _resume_artwork(self) -> None:
with contextlib.suppress(NoMatches):
self.query_one(ArtPane).resume()
def _cancel_workers(self) -> None:
for group in (
"stream",
"history",
"search",
"stations",
"stations-page",
"schedule",
"bandcamp",
"artwork",
):
with contextlib.suppress(Exception):
self.workers.cancel_group(self, group)
def _switch_catalogue(self, provider_id: str) -> None:
"""Move to another station catalogue without restarting the app."""
provider = get_provider(provider_id)
if provider is self.provider:
return
self._cancel_workers()
with contextlib.suppress(Exception):
self.player.stop()
self.spectrum.set_source(None)
self.playing_state = "stopped"
self.media.set_status("Stopped")
old_client = self._http
self.provider = provider
self._http = make_client(provider.user_agent)
if old_client is not None:
with contextlib.suppress(RuntimeError):
asyncio.get_running_loop().create_task(old_client.aclose())
self.config.provider = provider.id
self.config.save()
self.stations = []
self.view = []
self.index = 0
self.search_results = None
self._search_results_query = ""
self.search_pending = False
self._discovered_station_ids.clear()
self.current = None
self.schedule = []
self.schedule_loaded = False
self._last_schedule_fetch = 0.0
self._last_history_fetch = 0.0
self._bandcamp_key = ""
self._bandcamp_urls.clear()
self._stream_url = ""
self._art_track_key = ""
self.artwork_url = ""
self.artwork_bytes = None
self.start_station_id = self.config.last_station_for(provider.id)
self._apply_catalogue_chrome()
with contextlib.suppress(NoMatches):
self._reset_filter()
stations = provider.load_cached_stations()
cached = bool(stations)
if not stations:
stations = provider.fallback_stations()
self._set_stations(stations)
self._reset_directory_paging(stations, has_more=cached)
self._select_initial_station()
self._update_media()
self._update_artwork()
self._refresh_art()
with contextlib.suppress(NoMatches):
self.query_one(HeaderBar).refresh()
self.query_one(StagePanel).refresh_stage()
self.query_one(StatusBar).refresh()
if self.audio_enabled and len(stations) == 1 and self.current is not None:
# A catalogue with one station has nothing to choose between:
# switching to it is the same as tuning it.
self._tune(self.current)
else:
self._set_notice(f"catalogue: {provider.name}", 3)
if not self.offline:
self._refresh_stations()
if self.current is not None:
self._fetch_history(self.current.id)
if provider.has_schedule:
self._refresh_schedule()
def action_help(self) -> None:
self.push_screen(HelpScreen())
self._suspend_artwork()
self.push_screen(HelpScreen(), lambda _result: self._resume_artwork())
def action_quit(self) -> None:
self._cleanup()
@@ -876,15 +1427,27 @@ class BeefyPlayerApp(App[None]):
if event.input.id != "search":
return
self.query = event.value
self.search_results = None
self._search_results_query = ""
self.search_pending = (
self.provider.server_search
and not self.offline
and bool(event.value.strip())
)
self._rebuild_view()
self.query_one(StationList).set_filter(self.query)
self.query_one(StationList).refresh()
if self.provider.server_search:
self._search_directory(event.value)
self._maybe_load_more_stations()
def on_input_submitted(self, event: Input.Submitted) -> None:
if event.input.id != "search":
return
station = self.view[self.index] if self.view else None
self._close_search(clear=True)
# Enter tunes, and leaves the filter in place exactly like the first
# escape does; a second escape returns to the regular station list.
self._close_search(clear=False)
if station is not None:
self._tune(station)
+22 -6
View File
@@ -230,22 +230,38 @@ async def fetch(client: httpx.AsyncClient, url: str) -> bytes | None:
def fit_image(data: bytes, width: int, height: int):
"""Decode and center-crop an image to exactly ``width`` x ``height`` cells."""
"""Decode and letterbox an image into exactly ``width`` x ``height`` cells.
Scale to fit, never crop. Square album art fills the box either way, but a
wide station wordmark (Bassdrive's logo is 3:1) used to be centre-cropped
down to a zoomed slice of its middle. The padding stays transparent, so
the mosaic behind the artwork shows through instead of a black bar.
"""
try:
from PIL import Image, ImageOps
with Image.open(BytesIO(data)) as source:
# A PNG with transparency is drawn *over* the mosaic, which reads
# as noise behind a logo; composite those onto black instead.
has_alpha = source.mode in ("RGBA", "LA", "PA", "RGBa", "La") or (
"transparency" in source.info
)
image = source.convert("RGBA")
cell_width, cell_height = cell_size()
target = (
max(1, width * cell_width),
max(1, height * cell_height),
)
return ImageOps.fit(
image,
target,
method=Image.Resampling.LANCZOS,
centering=(0.5, 0.5),
fitted = ImageOps.contain(image, target, method=Image.Resampling.LANCZOS)
background = (0, 0, 0, 255) if has_alpha else (0, 0, 0, 0)
canvas = Image.new("RGBA", target, background)
canvas.alpha_composite(
fitted,
(
(target[0] - fitted.width) // 2,
(target[1] - fitted.height) // 2,
),
)
return canvas
except Exception:
return None
+47 -1
View File
@@ -3,7 +3,9 @@
Everything lives in the usual XDG places:
~/.config/beefy-player/config.json favourites, volume, station
~/.cache/beefy-player/channels.json last good station list
~/.cache/beefy-player/channels.json last good station list (SomaFM)
~/.cache/beefy-player/channels-<id>.json ... for every other catalogue
~/.cache/beefy-player/schedule-<id>.json last good broadcast schedule
~/.cache/beefy-player/playlists.json resolved .pls -> stream URLs
~/.cache/beefy-player/songs/<id>.json last good song history
~/.cache/beefy-player/artwork/<sha1> cached album/channel artwork
@@ -17,12 +19,14 @@ from __future__ import annotations
import contextlib
import json
import os
import re
import shutil
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
from . import __version__
from .palettes import DEFAULT_PALETTE, PALETTES
#: Directory name for config, cache and runtime files.
APP_NAME = "beefy-player"
@@ -69,6 +73,27 @@ LEGACY_CONFIG_PATH = _xdg("XDG_CONFIG_HOME", ".config") / LEGACY_APP_NAME / "con
VISUALIZERS = ("both", "bars", "wave", "waterfall", "radial", "braille")
#: Colour palettes the UI can draw with (see :mod:`beefy_player.palettes`).
PALETTE_NAMES = tuple(PALETTES)
def channels_cache_path(provider_id: str) -> Path:
"""Where a catalogue caches its last good station list.
SomaFM keeps the historical ``channels.json``; every other provider gets
its own file, so two catalogues can never overwrite each other's cache.
"""
slug = re.sub(r"[^a-z0-9_-]", "_", provider_id.strip().lower())
if not slug or slug == "somafm":
return CHANNELS_CACHE
return CACHE_DIR / f"channels-{slug}.json"
def schedule_cache_path(provider_id: str) -> Path:
"""Where a catalogue caches its last good broadcast schedule."""
slug = re.sub(r"[^a-z0-9_-]", "_", provider_id.strip().lower()) or "provider"
return CACHE_DIR / f"schedule-{slug}.json"
def ensure_dirs() -> None:
for directory in (CONFIG_DIR, CACHE_DIR, SONGS_CACHE_DIR):
@@ -115,8 +140,13 @@ def migrate_legacy_state() -> None:
class Config:
favorites: list[str] = field(default_factory=list)
last_station: str = "groovesalad"
#: Which station each catalogue was last tuned to, keyed by provider slug,
#: so switching back lands where you left off.
last_stations: dict[str, str] = field(default_factory=dict)
volume: int = 70
visualizer: str = "both"
#: Colour palette name — spectrum, ocean, ember, …
palette: str = DEFAULT_PALETTE
autoplay: bool = False
#: Which catalogue to use by default (see :mod:`beefy_player.providers`).
provider: str = "somafm"
@@ -141,6 +171,14 @@ class Config:
if isinstance(last_station, str) and last_station:
config.last_station = last_station
last_stations = raw.get("last_stations")
if isinstance(last_stations, dict):
config.last_stations = {
str(key): str(value)
for key, value in last_stations.items()
if isinstance(value, str) and value
}
volume = raw.get("volume")
if isinstance(volume, (int, float)):
config.volume = max(0, min(100, int(volume)))
@@ -149,6 +187,10 @@ class Config:
if visualizer in VISUALIZERS:
config.visualizer = visualizer
palette = raw.get("palette")
if isinstance(palette, str) and palette in PALETTE_NAMES:
config.palette = palette
provider = raw.get("provider")
if isinstance(provider, str) and provider.strip():
config.provider = provider.strip().lower()
@@ -168,6 +210,10 @@ class Config:
except OSError:
pass
def last_station_for(self, provider_id: str) -> str:
"""The station to re-tune for a catalogue (old configs have one only)."""
return self.last_stations.get(provider_id) or self.last_station
def is_favorite(self, station_id: str) -> bool:
return station_id in self.favorites
+40 -1
View File
@@ -40,6 +40,27 @@ class Song:
return f"{self.artist}\x1f{self.title}\x1f{self.album}"
@dataclass(slots=True)
class Broadcast:
"""One entry in a catalogue's schedule: a show, a DJ slot, a stream."""
name: str
dj: str = ""
#: Where the broadcast comes from ("Poznan, Poland"), when the feed says.
location: str = ""
start: datetime | None = None
end: datetime | None = None
#: True for the broadcast that is on air right now.
live: bool = False
@property
def display_name(self) -> str:
return self.name.strip() or self.dj.strip() or "untitled broadcast"
def key(self) -> str:
return f"{self.name}\x1f{self.start.isoformat() if self.start else ''}"
@dataclass(slots=True)
class Station:
id: str
@@ -47,6 +68,9 @@ class Station:
description: str = ""
genre: str = ""
dj: str = ""
#: Who is presenting right now when that is more than a DJ name —
#: ``"auto dj · library rotation"`` for a station between live shows.
presenter: str = ""
image: str = ""
listeners: int = 0
last_playing: str = ""
@@ -69,6 +93,15 @@ class Station:
def genre_label(self) -> str:
return self.genre.replace("|", " · ") or "eclectic"
@property
def presenter_label(self) -> str:
"""What the ON AIR metadata line says about who is on the desk."""
if self.presenter.strip():
return self.presenter.strip()
if self.dj.strip():
return f"dj {self.dj.strip()}"
return ""
def sorted_playlists(self) -> list[dict[str, str]]:
return sorted(
self.playlists,
@@ -87,7 +120,7 @@ class Station:
fmt = _FORMAT_LABEL.get(
first.get("format", ""), first.get("format") or "stream"
)
return f"{fmt} · {guess_bitrate(first.get('url', ''))}k"
return f"{fmt} · {playlist_bitrate(first)}k"
# --------------------------------------------------------------------------- #
@@ -95,6 +128,12 @@ class Station:
# --------------------------------------------------------------------------- #
def playlist_bitrate(playlist: dict[str, str]) -> int:
"""The bitrate a catalogue declared, else the best guess from the URL."""
declared = to_int(playlist.get("bitrate"))
return declared or guess_bitrate(playlist.get("url", ""))
def guess_bitrate(url: str) -> int:
match = re.search(
r"(256|192|130|128|96|64|48|32)(?=\.pls|\.m3u|-mp3|-aac)", url
+119
View File
@@ -0,0 +1,119 @@
"""The colour palettes everything in the UI draws from.
Every colour in Beefy Player is a stock ANSI palette *name*, never RGB, so the
TUI adopts whatever colours the terminal is configured with. A palette here is
therefore a short ladder of those names — the "gradient" is a quantised walk
from the quiet end to the hot end, not an interpolation.
The active palette drives the spectrum analyser, the album-art mosaic, the
provider wordmarks and the pane corners; `c` cycles through them and the
choice is remembered in the config file.
"""
from __future__ import annotations
#: Dark → hot ladders of ANSI names. Sixteen of them: seven hue gradients, a
#: few near-monochromes, and the original spectrum.
PALETTES: dict[str, tuple[str, ...]] = {
"spectrum": (
"cyan",
"blue",
"magenta",
"bright_magenta",
"yellow",
"bright_yellow",
"red",
),
"rainbow": (
"red",
"bright_yellow",
"bright_green",
"bright_cyan",
"bright_blue",
"bright_magenta",
),
"ocean": (
"bright_black",
"blue",
"bright_blue",
"cyan",
"bright_cyan",
"bright_white",
),
"ember": (
"bright_black",
"red",
"bright_red",
"yellow",
"bright_yellow",
"bright_white",
),
"mint": (
"bright_black",
"green",
"bright_green",
"cyan",
"bright_cyan",
"bright_white",
),
"sunset": ("magenta", "bright_magenta", "red", "bright_red", "yellow"),
"violet": ("blue", "bright_blue", "magenta", "bright_magenta", "bright_white"),
"acid": ("bright_black", "green", "bright_green", "yellow", "bright_yellow"),
"neon": (
"bright_magenta",
"bright_red",
"bright_yellow",
"bright_green",
"bright_cyan",
),
"sand": ("bright_black", "yellow", "bright_yellow", "white", "bright_white"),
"blood": ("bright_black", "red", "bright_red", "bright_magenta"),
"forest": ("bright_black", "green", "bright_green", "yellow", "bright_white"),
"ice": ("bright_white", "bright_cyan", "cyan", "bright_blue", "blue"),
"steel": ("bright_black", "white", "bright_white", "bright_cyan"),
"candy": ("magenta", "bright_magenta", "bright_cyan", "bright_yellow"),
"mono": ("bright_black", "white", "bright_white"),
}
#: The stock names from dark to bright. These are xterm's default values in
#: Rec. 601 luma order — the closest thing to a stock answer. A theme may
#: disagree about the exact numbers, but the ordering is what the frame fades
#: need: a ladder is a *hue* walk, so where it ends says nothing about which
#: end is brighter.
BRIGHTNESS: tuple[str, ...] = (
"black",
"blue",
"red",
"bright_red",
"magenta",
"bright_magenta",
"bright_blue",
"green",
"bright_black",
"cyan",
"bright_green",
"bright_cyan",
"yellow",
"white",
"bright_yellow",
"bright_white",
)
_RANK = {name: rank for rank, name in enumerate(BRIGHTNESS)}
#: Cycle order for the palette key.
PALETTE_ORDER: tuple[str, ...] = tuple(PALETTES)
#: What you get until you press `c`.
DEFAULT_PALETTE = "spectrum"
def palette_stops(name: str | None) -> tuple[str, ...]:
"""The ladder for ``name``, falling back to the default palette."""
key = (name or "").strip().lower()
return PALETTES.get(key, PALETTES[DEFAULT_PALETTE])
def brightness(colour: str) -> int:
"""How bright a stock name is: 0 (black) to 15 (bright white)."""
return _RANK.get(colour.strip().lower(), 0)
+35 -2
View File
@@ -17,7 +17,21 @@ Adding a catalogue is meant to be a small, contained change:
from __future__ import annotations
from .base import RadioProvider
from .bassdrive import BassdriveProvider
from .community import (
AnonradioProvider,
CashmereProvider,
DatafruitsProvider,
DublabProvider,
LotProvider,
LylProvider,
RefugeProvider,
RinseProvider,
)
from .radiobrowser import RadioBrowserProvider
from .radioparadise import RadioParadiseProvider
from .somafm import SomaFMProvider
from .tilderadio import TilderadioProvider
__all__ = [
"DEFAULT_PROVIDER",
@@ -27,9 +41,28 @@ __all__ = [
"get_provider",
]
#: Every catalogue the app can talk to, keyed by its slug.
#: Every catalogue the app can talk to, keyed by its slug. The picker, the
#: help screen and ``--list`` all read this order: alphabetical by name.
PROVIDERS: dict[str, RadioProvider] = {
provider.id: provider for provider in (SomaFMProvider(),)
provider.id: provider
for provider in sorted(
(
SomaFMProvider(),
TilderadioProvider(),
BassdriveProvider(),
RadioBrowserProvider(),
RadioParadiseProvider(),
DublabProvider(),
RinseProvider(),
RefugeProvider(),
LylProvider(),
LotProvider(),
CashmereProvider(),
DatafruitsProvider(),
AnonradioProvider(),
),
key=lambda provider: provider.name.casefold(),
)
}
#: Provider used when nothing else is configured.
+91 -2
View File
@@ -18,17 +18,19 @@ import asyncio
import re
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
from urllib.parse import quote
import httpx
from .. import config
from ..models import Song, Station
from ..models import Broadcast, Song, Station
__all__ = [
"PLAYLIST_TTL",
"RadioProvider",
"StationPage",
"fetch_json",
"is_direct_stream",
"is_playlist",
@@ -62,6 +64,15 @@ def reset_playlist_cache() -> None:
_PLAYLIST_CACHE = None
@dataclass(slots=True)
class StationPage:
"""One page of a large catalogue, in the catalogue's own order."""
stations: list[Station]
#: Offset to request next, or ``None`` when the catalogue is exhausted.
next_offset: int | None = None
# --------------------------------------------------------------------------- #
# the interface
# --------------------------------------------------------------------------- #
@@ -92,6 +103,42 @@ class RadioProvider(ABC):
#: ``User-Agent`` for this catalogue's requests (SomaFM likes to know).
user_agent: str = ""
# -- header hero ----------------------------------------------------- #
#: Rows of header art drawn at the top left (rich markup is allowed).
hero: tuple[str, ...] = ()
#: Paint :attr:`hero` with the app's spectrum gradient (a wordmark) instead
#: of the colours the art carries itself (a picture).
hero_gradient: bool = False
#: Short lines printed beside the hero, top to bottom.
hero_lines: tuple[str, ...] = ()
# -- left pane ------------------------------------------------------- #
#: Show a broadcast schedule in the left pane instead of a station list.
#: Set by single-stream catalogues, where a list of one station is a waste
#: of the pane and "what is on, what is next" is the useful view.
has_schedule: bool = False
#: Look the current track up on Bandcamp and offer a link when it is found.
#: Off for catalogues whose rotation is unlikely to be there (and for
#: stations that would rather not be scraped).
bandcamp: bool = True
#: The catalogue can search its whole directory on the server. When true,
#: the app calls :meth:`search_stations` as the filter changes instead of
#: only filtering the (possibly small) list from :meth:`fetch_stations`.
server_search: bool = False
#: The catalogue is larger than one page. When true, the app fetches the
#: first page with :meth:`fetch_station_page` and asks for the next one as
#: the station list nears its end.
paginated: bool = False
#: Keep the catalogue's own ordering (popularity, schedule, ...) instead of
#: sorting the station list alphabetically.
preserve_order: bool = False
# -- catalogue ------------------------------------------------------- #
@abstractmethod
@@ -106,6 +153,37 @@ class RadioProvider(ABC):
def fallback_stations(self) -> list[Station]:
"""A bundled list, so a fresh offline start still works."""
async def search_stations(
self, client: httpx.AsyncClient, query: str
) -> list[Station]:
"""Search the catalogue for *query*.
Only called when :attr:`server_search` is true. Providers that search
locally leave this alone; the app filters the loaded list itself.
"""
return []
async def fetch_station_page(
self, client: httpx.AsyncClient, offset: int = 0
) -> StationPage:
"""One page of a paginated catalogue.
Only called when :attr:`paginated` is true. ``offset`` is the value
from the previous page's :attr:`StationPage.next_offset`.
"""
return StationPage(stations=[], next_offset=None)
async def fetch_stations_by_ids(
self, client: httpx.AsyncClient, station_ids: list[str]
) -> list[Station]:
"""Fetch specific stations by id.
Used for favourites that are not on the first page of a paginated
catalogue. Providers that return their whole catalogue at once can
leave this alone.
"""
return []
# -- metadata -------------------------------------------------------- #
@abstractmethod
@@ -118,6 +196,16 @@ class RadioProvider(ABC):
def load_cached_history(self, station_id: str) -> list[Song]:
"""The last good song history from disk, or ``[]``."""
# -- schedule -------------------------------------------------------- #
async def fetch_schedule(self, client: httpx.AsyncClient) -> list[Broadcast]:
"""Upcoming broadcasts, soonest first (the live one, if any, first)."""
return []
def load_cached_schedule(self) -> list[Broadcast]:
"""The last good schedule from disk, or ``[]``."""
return []
# -- streams --------------------------------------------------------- #
def fallback_urls(self, station: Station) -> list[str]:
@@ -162,8 +250,9 @@ class RadioProvider(ABC):
def is_playlist(url: str) -> bool:
"""A playlist *we* resolve; HLS is handed to the player untouched."""
path = url.split("?", 1)[0].split("#", 1)[0].lower()
return path.endswith((".pls", ".m3u", ".m3u8"))
return path.endswith((".pls", ".m3u"))
def is_direct_stream(url: str) -> bool:
@@ -0,0 +1,76 @@
"""Bassdrive: worldwide drum & bass, on air since the dial-up days.
One stream with three mounts, a schedule full of live DJ shows and a
``display-title`` that names the show rather than the track. Everything
Bassdrive-specific — the Icecast status schema, the homepage schedule grid, the
rolling show history and the bundled fallback — lives in this package; the app
only sees :class:`BassdriveProvider`.
"""
from __future__ import annotations
import httpx
from ... import config
from ...models import Broadcast, Song, Station
from ..base import RadioProvider
from . import api, fallback
from . import hero as hero_art
__all__ = ["BassdriveProvider"]
class BassdriveProvider(RadioProvider):
id = "bassdrive"
name = "Bassdrive"
homepage = "bassdrive.com"
brand = "BASSDRIVE"
tagline = "worldwide drum & bass · live shows"
station_source = "bassdrive.com + icecast status"
history_source = "icecast status (rolling)"
# The wordmark in the station's own two blues, in the shared half-block
# font; the caption is the station's own description.
hero = hero_art.HERO
hero_lines = (
"BASSDRIVE",
"WORLDWIDE DRUM & BASS",
"LIVE SHOWS 24/7",
)
# One stream and a schedule full of DJ slots: the left pane is the schedule.
has_schedule = True
# DJ-mix station: the rotation is not a Bandcamp catalogue.
bandcamp = False
@property
def user_agent(self) -> str:
return f"{config.USER_AGENT}; +https://bassdrive.com"
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
return [await api.fetch_station(client)]
def load_cached_stations(self) -> list[Station]:
return api.load_cached_station()
def fallback_stations(self) -> list[Station]:
return fallback.fallback_stations()
async def fetch_history(
self, client: httpx.AsyncClient, station_id: str
) -> list[Song]:
return await api.fetch_songs(client, station_id)
def load_cached_history(self, station_id: str) -> list[Song]:
return api.load_cached_songs(station_id)
async def fetch_schedule(
self, client: httpx.AsyncClient
) -> list[Broadcast]:
return await api.fetch_schedule(client)
def load_cached_schedule(self) -> list[Broadcast]:
return api.load_cached_schedule()
def fallback_urls(self, station: Station) -> list[str]:
return fallback.known_stream_urls()
+517
View File
@@ -0,0 +1,517 @@
"""Bassdrive's public endpoints, and nothing but.
* ``https://ice.bassdrive.net/status-json.xsl`` -> Icecast status: every mount
(mp3/aac+, bitrates), the listener counts and the current ``display-title``,
which for a DJ-mix stream is the *show*: ``"Ben XO :: LIVE :: Night Shift
Over"``.
* ``http://www.bassdrive.com/`` -> the homepage carries the whole week's
schedule, every slot stamped with an absolute ``<local-time datetime=...>``.
A DJ-mix station has no track history: Icecast reports one title and forgets
it. So "now playing" is the show (mapped onto ``Station.dj``/``presenter`` and
a :class:`Song`), and the history is a rolling record of the shows we have
heard since the app started — the unit a listener actually cares about here.
"""
from __future__ import annotations
import asyncio
import html as html_module
import re
import time
from collections.abc import Iterable
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
import httpx
from ... import config
from ...models import Broadcast, Song, Station, parse_date, to_int
from ..base import fetch_json
PROVIDER_ID = "bassdrive"
STATUS_URL = "https://ice.bassdrive.net/status-json.xsl"
SCHEDULE_URL = "https://www.bassdrive.com/"
DEFAULT_STATION_ID = "bassdrive"
STATION_IMAGE = "https://www.bassdrive.com/images/logo.png"
DESCRIPTION = "worldwide drum & bass radio — live DJ shows around the clock"
GENRE = "drum & bass|jungle"
WEEK = timedelta(days=7)
#: Upcoming slots kept in the pane (a week is plenty for a schedule).
SCHEDULE_LIMIT = 40
#: Shows remembered in LAST PLAYED.
HISTORY_LIMIT = 20
# --------------------------------------------------------------------------- #
# status-json -> Station / Song
# --------------------------------------------------------------------------- #
def _sources(payload: Any) -> list[dict[str, Any]]:
stats = payload.get("icestats") if isinstance(payload, dict) else None
source = stats.get("source") if isinstance(stats, dict) else None
if isinstance(source, dict):
return [source]
if isinstance(source, list):
return [entry for entry in source if isinstance(entry, dict)]
return []
def _quality(bitrate: int) -> str:
if bitrate >= 128:
return "highest"
if bitrate >= 48:
return "high"
return "low" if bitrate else ""
def playlists_from_status(payload: Any) -> list[dict[str, str]]:
playlists: list[dict[str, str]] = []
seen: set[str] = set()
for source in _sources(payload):
url = source.get("listenurl")
if not isinstance(url, str) or not url.startswith(("http://", "https://")):
continue
if url in seen:
continue
seen.add(url)
bitrate = to_int(source.get("bitrate"))
kind = str(source.get("server_type") or "").lower()
fmt = "mp3" if "mpeg" in kind else "aacp" if "aac" in kind else ""
playlists.append(
{
"url": url,
"format": fmt,
"quality": _quality(bitrate),
"bitrate": str(bitrate) if bitrate else "",
}
)
return playlists
def status_listeners(payload: Any) -> int:
"""Mounts split the audience, so the station's figure is their sum."""
return sum(to_int(source.get("listeners")) for source in _sources(payload))
def display_title(payload: Any) -> str:
for source in _sources(payload):
for key in ("display-title", "title"):
value = source.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def split_title(raw: str) -> tuple[str, str, bool]:
"""``"Ben XO :: LIVE :: Night Shift Over [promo]"`` -> DJ, show, live.
Bassdrive's title convention is ``DJ :: LIVE :: Show``, with promotional
brackets tacked on the end — but it is not enforced: plenty of shows go out
as ``"Sohl Patrol LIVE"``, so a trailing ``LIVE`` counts too. Anything
unexpected degrades to "the whole string is the show".
"""
cleaned = re.sub(r"\[[^\]]*\]", " ", raw)
parts = [part.strip() for part in cleaned.split("::") if part.strip()]
live = any(part.casefold() == "live" for part in parts)
parts = [part for part in parts if part.casefold() != "live"]
if parts:
trailing = re.search(r"(?i)\s+live$", parts[-1])
if trailing:
live = True
trimmed = parts[-1][: trailing.start()].strip()
parts = parts[:-1] + ([trimmed] if trimmed else [])
if not parts:
return "", raw.strip(), live
if len(parts) == 1:
return "", parts[0], live
return parts[0], parts[-1], live
def song_from_title(raw: str) -> Song | None:
dj, show, _live = split_title(raw)
if not (dj or show):
return None
# With no DJ in the title the station stands in as the "artist": a panel
# that says "Unknown artist" for every show would look broken.
return Song(artist=dj or "Bassdrive", title=show)
def _same_show(entry: Broadcast, show: str) -> bool:
return bool(show) and entry.name.strip().casefold() == show.strip().casefold()
def _slot_for(schedule: Iterable[Broadcast], show: str) -> Broadcast | None:
if not show:
return None
return next((entry for entry in schedule if _same_show(entry, show)), None)
def resolve_now(
payload: Any,
schedule: Iterable[Broadcast] = (),
now: datetime | None = None,
) -> tuple[str, str, bool, Broadcast | None]:
"""Who is on, according to the stream *and* the schedule.
The display-title often forgets the ``LIVE`` marker; a show whose slot
covers now counts as live even when the title is just its name.
"""
now = now or datetime.now(timezone.utc)
schedule = list(schedule)
dj, show, live = split_title(display_title(payload))
slot = _slot_for(schedule, show)
if (
slot is not None
and slot.start is not None
and slot.end is not None
and slot.start <= now < slot.end
):
live = True
return dj, show, live, slot
def station_from_status(
payload: Any, schedule: Iterable[Broadcast] = ()
) -> Station | None:
playlists = playlists_from_status(payload)
raw = display_title(payload)
if not playlists and not raw:
return None
dj, show, live, slot = resolve_now(payload, schedule)
location = slot.location if slot is not None else ""
if live:
presenter = f"live from {location}" if location else "live now"
else:
presenter = "rotation · drum & bass"
station = Station(
id=DEFAULT_STATION_ID,
title="Bassdrive",
description=DESCRIPTION,
genre=GENRE,
dj=dj,
presenter=presenter,
image=STATION_IMAGE,
listeners=status_listeners(payload),
playlists=playlists,
provider=PROVIDER_ID,
)
if show:
# The "artist" of a show-based stream is the station itself.
station.now_playing = Song(artist=dj or "Bassdrive", title=show)
station.last_playing = raw
return station
def live_broadcast(
payload: Any,
schedule: Iterable[Broadcast],
now: datetime | None = None,
) -> Broadcast | None:
dj, show, live, slot = resolve_now(payload, schedule, now)
if not live or not show:
return None
if slot is not None:
return Broadcast(
name=show,
dj=dj,
location=slot.location,
start=slot.start,
end=slot.end,
live=True,
)
return Broadcast(name=show, dj=dj, live=True)
# --------------------------------------------------------------------------- #
# the homepage schedule
# --------------------------------------------------------------------------- #
_SPAN_LOCATION = re.compile(r'<span class="location">(.*?)</span>', re.S)
def _text(value: str) -> str:
return re.sub(r"\s+", " ", html_module.unescape(re.sub(r"<[^>]+>", " ", value))).strip()
def parse_schedule(page: str) -> list[Broadcast]:
"""Pull the weekly grid out of the homepage, one ``<li>`` per slot."""
broadcasts: list[Broadcast] = []
for chunk in re.split(r"<li\b", page):
if 'class="title"' not in chunk or 'class="time"' not in chunk:
continue
stamps = re.findall(r'datetime="([^"]+)"', chunk)
if len(stamps) < 2:
continue
start = parse_date(stamps[0])
end = parse_date(stamps[1])
if start is None:
continue
title = re.search(r'<p class="title">(.*?)</p>', chunk, re.S)
if title is None:
continue
block = title.group(1)
location_match = _SPAN_LOCATION.search(block)
location = _text(location_match.group(1)) if location_match else ""
name = _text(_SPAN_LOCATION.sub(" ", block))
if not name:
continue
broadcasts.append(
Broadcast(name=name, location=location, start=start, end=end)
)
broadcasts.sort(key=lambda item: item.start or datetime.now(timezone.utc))
return broadcasts
def roll_forward(
entries: Iterable[Broadcast], now: datetime, limit: int = SCHEDULE_LIMIT
) -> list[Broadcast]:
"""The homepage only shows one week; repeat it so the pane is never empty."""
upcoming: list[Broadcast] = []
for entry in entries:
start, end = entry.start, entry.end
if start is None:
continue
for _ in range(3):
if end is None or end >= now:
break
start += WEEK
end = end + WEEK if end is not None else None
if end is not None and end < now:
continue
upcoming.append(
Broadcast(
name=entry.name,
dj=entry.dj,
location=entry.location,
start=start,
end=end,
)
)
upcoming.sort(key=lambda item: item.start or now)
return upcoming[:limit]
# --------------------------------------------------------------------------- #
# caching
# --------------------------------------------------------------------------- #
#: One homepage fetch serves both the station and the schedule worker; the
#: lock keeps a cold start from downloading the same grid twice at once.
_MEMO_TTL = 120.0
_schedule_memo: tuple[float, list[Broadcast]] | None = None
_schedule_lock = asyncio.Lock()
def _memo_schedule() -> list[Broadcast]:
if _schedule_memo is None:
return []
fetched, entries = _schedule_memo
if time.time() - fetched > _MEMO_TTL:
return []
return list(entries)
def _remember_schedule(entries: list[Broadcast]) -> None:
global _schedule_memo
_schedule_memo = (time.time(), entries)
def reset_schedule_memo() -> None:
"""Forget the in-process schedule (the tests use this)."""
global _schedule_memo
_schedule_memo = None
def _songs_cache_path(station_id: str) -> Path:
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", station_id or DEFAULT_STATION_ID)
return config.SONGS_CACHE_DIR / f"{PROVIDER_ID}-{safe}.json"
async def schedule_entries(client: httpx.AsyncClient) -> list[Broadcast]:
"""The weekly grid: memo, then the homepage, then the cached copy."""
remembered = _memo_schedule()
if remembered:
return remembered
async with _schedule_lock:
remembered = _memo_schedule()
if remembered:
return remembered
try:
response = await client.get(
SCHEDULE_URL, headers={"Accept": "text/html"}
)
response.raise_for_status()
entries = parse_schedule(response.text)
except httpx.HTTPError:
entries = []
if not entries:
return load_cached_schedule()
_remember_schedule(entries)
config.write_json(
config.schedule_cache_path(PROVIDER_ID),
{
"fetched": time.time(),
"schedule": [
{
"name": item.name,
"location": item.location,
"start": item.start.isoformat() if item.start else "",
"end": item.end.isoformat() if item.end else "",
}
for item in entries
],
},
)
return entries
def load_cached_station() -> list[Station]:
raw = config.read_json(config.channels_cache_path(PROVIDER_ID))
status = raw.get("status") if isinstance(raw, dict) else None
if status is None:
return []
station = station_from_status(status, load_cached_schedule())
return [station] if station is not None else []
def load_cached_schedule() -> list[Broadcast]:
raw = config.read_json(config.schedule_cache_path(PROVIDER_ID))
entries = raw.get("schedule") if isinstance(raw, dict) else None
if not isinstance(entries, list):
return []
return _schedule_from_cache(entries)
def _schedule_from_cache(entries: list[Any]) -> list[Broadcast]:
broadcasts: list[Broadcast] = []
for entry in entries:
if not isinstance(entry, dict):
continue
start = parse_date(entry.get("start"))
end = parse_date(entry.get("end"))
name = str(entry.get("name") or "").strip()
if not name or start is None:
continue
broadcasts.append(
Broadcast(
name=name,
location=str(entry.get("location") or "").strip(),
start=start,
end=end,
)
)
broadcasts.sort(key=lambda item: item.start or datetime.now(timezone.utc))
return broadcasts
def load_cached_songs(station_id: str) -> list[Song]:
raw = config.read_json(_songs_cache_path(station_id))
entries = raw.get("songs") if isinstance(raw, dict) else None
if not isinstance(entries, list):
return []
songs: list[Song] = []
for entry in entries:
if not isinstance(entry, dict):
continue
song = Song(
title=str(entry.get("title") or "").strip(),
artist=str(entry.get("artist") or "").strip(),
played_at=parse_date(entry.get("played_at")),
)
if song.title or song.artist:
songs.append(song)
return songs
def _songs_to_cache(songs: Iterable[Song]) -> list[dict[str, Any]]:
return [
{
"title": song.title,
"artist": song.artist,
"played_at": song.played_at.isoformat() if song.played_at else "",
}
for song in songs
]
# --------------------------------------------------------------------------- #
# fetching
# --------------------------------------------------------------------------- #
async def fetch_station(client: httpx.AsyncClient) -> Station:
try:
payload = await fetch_json(client, STATUS_URL)
except RuntimeError as exc:
raise RuntimeError(f"could not fetch the Bassdrive status: {exc}") from None
# The schedule tells the station *where* a live show is coming from, so a
# cold start fetches it here; the schedule worker then reuses the memo.
schedule = load_cached_schedule() or await schedule_entries(client)
station = station_from_status(payload, schedule)
if station is None:
raise RuntimeError("Bassdrive returned no station data")
config.write_json(
config.channels_cache_path(PROVIDER_ID),
{"fetched": time.time(), "status": payload},
)
return station
async def fetch_songs(client: httpx.AsyncClient, station_id: str) -> list[Song]:
try:
payload = await fetch_json(client, STATUS_URL, attempts=1)
except RuntimeError:
return load_cached_songs(station_id)
current = song_from_title(display_title(payload))
if current is None:
return load_cached_songs(station_id)
history = load_cached_songs(station_id)
now = datetime.now(timezone.utc)
if history and history[0].key() == current.key():
# Same show still on air: keep the time we first heard it, so the pane
# says how long it has been running, not how long since the last poll.
current.played_at = history[0].played_at
history = [current, *history[1:]]
else:
current.played_at = now
history = [current] + [song for song in history if song.key() != current.key()]
history = history[:HISTORY_LIMIT]
config.write_json(
_songs_cache_path(station_id),
{"fetched": time.time(), "songs": _songs_to_cache(history)},
)
return history
async def fetch_schedule(client: httpx.AsyncClient) -> list[Broadcast]:
now = datetime.now(timezone.utc)
live: Broadcast | None = None
try:
status = await fetch_json(client, STATUS_URL, attempts=1)
except RuntimeError:
status = None
entries = await schedule_entries(client)
upcoming = roll_forward(entries, now)
if status is not None:
live = live_broadcast(status, entries, now)
if live is not None:
key = live.key()
upcoming = [item for item in upcoming if item.key() != key]
return [live, *upcoming]
return upcoming
@@ -0,0 +1,56 @@
"""Bundled station, so a cold offline start still makes sense.
Bassdrive is one station with three mounts and a handful of mirror hosts; the
streams below are the ones its own ``.m3u`` files list. The live status-json
always wins when it is reachable.
"""
from __future__ import annotations
from typing import Any
# (url, format, quality, bitrate)
_MOUNTS: tuple[tuple[str, str, str, str], ...] = (
("https://ice.bassdrive.net:443/stream", "mp3", "highest", "128"),
("https://ice.bassdrive.net:443/stream56", "aacp", "high", "56"),
("https://ice.bassdrive.net:443/stream32", "aacp", "low", "32"),
)
#: Alternative servers from bassdrive.m3u, tried when the primary is down.
_MIRRORS: tuple[str, ...] = (
"http://chi.bassdrive.co:80",
"http://bassdrive.radioca.st:8702",
"http://stream.bassdrive.uk:8200",
"http://au.bassdrive.co:8000",
)
_SERVER_TYPE = {"mp3": "audio/mpeg", "aacp": "audio/aacp"}
def fallback_status() -> dict[str, Any]:
"""A ``status-json.xsl``-shaped payload, so the normal parser can eat it."""
return {
"icestats": {
"source": [
{
"listenurl": url,
"bitrate": int(bitrate),
"server_type": _SERVER_TYPE.get(fmt, "audio/mpeg"),
"listeners": 0,
"display-title": "",
}
for url, fmt, _quality, bitrate in _MOUNTS
]
}
}
def fallback_stations() -> list[Any]:
from .api import station_from_status
station = station_from_status(fallback_status())
return [station] if station is not None else []
def known_stream_urls() -> list[str]:
return [url for url, _format, _quality, _bitrate in _MOUNTS] + list(_MIRRORS)
+14
View File
@@ -0,0 +1,14 @@
"""Bassdrive's header hero: its wordmark in the station's two blues.
The logo paints "BASS" in the pale blue and "DRIVE" in the deeper one; those
map onto the stock palette's bright cyan and cyan, so the wordmark still reads
as Bassdrive without hard-coded RGB.
"""
from __future__ import annotations
from ..wordmark import render
HERO: tuple[str, ...] = render(
"BASSDRIVE", ["bright_cyan"] * 4 + ["cyan"] * 5
)
@@ -0,0 +1,29 @@
"""Independent stations: one relay each, with the metadata the relay offers.
Each module here is a thin subclass of
:class:`beefy_player.providers.community.base.CommunityProvider`, which knows
how to read an Icecast status document or a radio.co status, keep a rolling
history and (for Dublab) follow a published schedule.
"""
from __future__ import annotations
from .anonradio import AnonradioProvider
from .cashmere import CashmereProvider
from .datafruits import DatafruitsProvider
from .dublab import DublabProvider
from .lot import LotProvider
from .lyl import LylProvider
from .refuge import RefugeProvider
from .rinse import RinseProvider
__all__ = [
"AnonradioProvider",
"CashmereProvider",
"DatafruitsProvider",
"DublabProvider",
"LotProvider",
"LylProvider",
"RefugeProvider",
"RinseProvider",
]
@@ -0,0 +1,26 @@
"""aNONradio: SDF's community station, on Icecast since the dial was young."""
from __future__ import annotations
from .base import CommunityProvider
class AnonradioProvider(CommunityProvider):
id = "anonradio"
name = "aNONradio"
homepage = "anonradio.net"
brand = "aNONradio"
tagline = "SDF community radio"
station_source = "anonradio.net icecast"
history_source = "icecast title (rolling)"
station_title = "aNONradio"
station_description = (
"community station of the SDF public-access UNIX system"
)
station_genre = "eclectic|talk"
streams = (("https://anonradio.net:8443/anonradio", "mp3", "high", "192"),)
status_url = "https://anonradio.net:8443/status-json.xsl"
wordmark = "ANONRADIO"
hero_lines = ("SDF COMMUNITY RADIO", "PUBLIC ACCESS UNIX")
+336
View File
@@ -0,0 +1,336 @@
"""Community stations: one relay each, sometimes with a status document.
Most independent stations are a single stream on Icecast, radio.co or Airtime,
plus — if you are lucky — a status document that names what is on. This module
turns that into a provider: the station, its relays, and a rolling history of
whatever the relay has announced while you were listening.
A subclass only carries data. Stations with a schedule or a multi-station
catalogue (Dublab, Rinse, Radio Paradise) override a hook or two.
"""
from __future__ import annotations
import re
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import httpx
from ... import config
from ...models import Broadcast, Song, Station, parse_date, parse_last_playing, to_int
from ..base import RadioProvider, fetch_json
from ..wordmark import render
#: ``public.radio.co`` status document: keyless, no station id in the body.
RADIO_CO_STATUS = "https://public.radio.co/stations/{station}/status"
#: How many shows/tracks LAST PLAYED remembers.
HISTORY_LIMIT = 20
class CommunityProvider(RadioProvider):
"""A catalogue that broadcasts one stream (or a fixed handful)."""
# -- catalogue data (set by each station module) ---------------------- #
station_title = ""
station_description = ""
station_genre = "eclectic"
station_image = ""
#: ``(url, format, quality, bitrate)`` — the relays, best first.
streams: tuple[tuple[str, str, str, str], ...] = ()
#: Keyless Icecast ``status-json.xsl``, when the relay publishes one.
status_url = ""
#: ``public.radio.co`` station id, when the relay is radio.co.
radio_co_id = ""
#: Wordmark the header spells; empty leaves the provider without a hero.
wordmark = ""
# Independent stations are not Bandcamp catalogues.
bandcamp = False
def __init__(self) -> None:
if self.wordmark:
self.hero = render(self.wordmark)
# ------------------------------------------------------------------ #
# station
# ------------------------------------------------------------------ #
def _playlists(self) -> list[dict[str, str]]:
return [
{"url": url, "format": fmt, "quality": quality, "bitrate": bitrate}
for url, fmt, quality, bitrate in self.streams
]
def station(self) -> Station:
return Station(
id=self.id,
title=self.station_title or self.name,
description=self.station_description or self.tagline,
genre=self.station_genre,
image=self.station_image,
playlists=self._playlists(),
provider=self.id,
)
def stations(self) -> list[Station]:
return [self.station()]
# ------------------------------------------------------------------ #
# what the relay says is on
# ------------------------------------------------------------------ #
@property
def has_status(self) -> bool:
return bool(self.status_url or self.radio_co_id)
async def _status(self, client: httpx.AsyncClient, attempts: int = 2) -> Any:
url = (
RADIO_CO_STATUS.format(station=self.radio_co_id)
if self.radio_co_id
else self.status_url
)
return await fetch_json(client, url, attempts=attempts)
def _listeners(self, payload: Any) -> int:
if not isinstance(payload, dict):
return 0
if self.radio_co_id:
return to_int(payload.get("listeners"))
stats = payload.get("icestats")
source = stats.get("source") if isinstance(stats, dict) else None
sources = (
source
if isinstance(source, list)
else [source]
if isinstance(source, dict)
else []
)
return sum(
to_int(item.get("listeners")) for item in sources if isinstance(item, dict)
)
def _icecast_title(self, payload: Any) -> str:
stats = payload.get("icestats") if isinstance(payload, dict) else None
source = stats.get("source") if isinstance(stats, dict) else None
sources = (
source
if isinstance(source, list)
else [source]
if isinstance(source, dict)
else []
)
for item in sources:
if not isinstance(item, dict):
continue
for key in ("display-title", "title", "yp_currently_playing"):
value = item.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def _now(self, payload: Any) -> Song | None:
"""The relay's current track/show, as a :class:`Song`."""
if not isinstance(payload, dict):
return None
if self.radio_co_id:
track = payload.get("current_track")
if not isinstance(track, dict):
return None
title = str(track.get("title") or "").strip()
if not title:
return None
song = parse_last_playing(title) or Song(title=title)
song.art_url = str(track.get("artwork_url") or "").strip()
song.played_at = parse_date(track.get("start_time"))
return song
title = self._icecast_title(payload)
if not title:
return None
return parse_last_playing(title)
# ------------------------------------------------------------------ #
# the schedule, when the catalogue has one (Dublab)
# ------------------------------------------------------------------ #
def _scheduled_now(self) -> Broadcast | None:
now = datetime.now(timezone.utc)
for entry in self.load_cached_schedule():
if entry.start is None or entry.end is None:
continue
if entry.start <= now < entry.end:
return entry
return None
def _mark_live(self, entries: list[Broadcast]) -> list[Broadcast]:
"""Flag the slot that is running now, so the pane can say ON AIR."""
now = datetime.now(timezone.utc)
marked: list[Broadcast] = []
for entry in entries:
if (
entry.start is not None
and entry.end is not None
and entry.start <= now < entry.end
):
marked.append(
Broadcast(
name=entry.name,
dj=entry.dj,
location=entry.location,
start=entry.start,
end=entry.end,
live=True,
)
)
else:
marked.append(entry)
return marked
def _scheduled_song(self) -> Song | None:
entry = self._scheduled_now()
if entry is None:
return None
return Song(artist=self.name, title=entry.name)
# ------------------------------------------------------------------ #
# caching
# ------------------------------------------------------------------ #
def _station_cache(self) -> Path:
return config.channels_cache_path(self.id)
def _songs_cache(self, station_id: str) -> Path:
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", station_id or self.id)
return config.SONGS_CACHE_DIR / f"{self.id}-{safe}.json"
def _songs_to_cache(self, songs: list[Song]) -> list[dict[str, str]]:
return [
{
"title": song.title,
"artist": song.artist,
"art": song.art_url,
"played_at": song.played_at.isoformat() if song.played_at else "",
}
for song in songs
]
# ------------------------------------------------------------------ #
# RadioProvider
# ------------------------------------------------------------------ #
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
stations = self.stations()
payload: Any = None
if self.has_status:
try:
payload = await self._status(client)
except RuntimeError:
payload = None
if payload is None:
payload = self._cached_status()
else:
config.write_json(
self._station_cache(), {"fetched": time.time(), "status": payload}
)
song = self._now(payload) if payload is not None else None
if song is None:
song = self._scheduled_song()
listeners = self._listeners(payload) if payload is not None else 0
entry = self._scheduled_now()
for station in stations:
if song is not None:
station.now_playing = song
station.last_playing = (
f"{song.artist} - {song.title}" if song.artist else song.title
)
if listeners:
station.listeners = listeners
if entry is not None:
station.presenter = (
f"live from {entry.location}" if entry.location else "live now"
)
return stations
def _cached_status(self) -> Any:
raw = config.read_json(self._station_cache())
return raw.get("status") if isinstance(raw, dict) else None
def load_cached_stations(self) -> list[Station]:
stations = self.stations()
payload = self._cached_status()
song = self._now(payload) if payload is not None else None
if song is None:
song = self._scheduled_song()
listeners = self._listeners(payload) if payload is not None else 0
for station in stations:
if song is not None:
station.now_playing = song
if listeners:
station.listeners = listeners
return stations
def fallback_stations(self) -> list[Station]:
return self.stations()
async def fetch_history(
self, client: httpx.AsyncClient, station_id: str
) -> list[Song]:
current: Song | None = None
if self.has_status:
try:
payload = await self._status(client, attempts=1)
except RuntimeError:
payload = None
if payload is not None:
current = self._now(payload)
if current is None:
current = self._scheduled_song()
if current is None:
return self.load_cached_history(station_id)
history = self.load_cached_history(station_id)
now = datetime.now(timezone.utc)
if history and history[0].key() == current.key():
# Same show or track: keep the time we first heard it.
current.played_at = history[0].played_at
history = [current, *history[1:]]
else:
current.played_at = current.played_at or now
history = [current, *[s for s in history if s.key() != current.key()]]
history = history[:HISTORY_LIMIT]
config.write_json(
self._songs_cache(station_id),
{"fetched": time.time(), "songs": self._songs_to_cache(history)},
)
return history
def load_cached_history(self, station_id: str) -> list[Song]:
raw = config.read_json(self._songs_cache(station_id))
entries = raw.get("songs") if isinstance(raw, dict) else None
if not isinstance(entries, list):
return []
songs: list[Song] = []
for entry in entries:
if not isinstance(entry, dict):
continue
song = Song(
title=str(entry.get("title") or "").strip(),
artist=str(entry.get("artist") or "").strip(),
art_url=str(entry.get("art") or "").strip(),
played_at=parse_date(entry.get("played_at")),
)
if song.title or song.artist:
songs.append(song)
return songs
def fallback_urls(self, station: Station) -> list[str]:
return [url for url, _format, _quality, _bitrate in self.streams]
@@ -0,0 +1,26 @@
"""Cashmere Radio: Berlin's experimental station, on an Airtime relay."""
from __future__ import annotations
from .base import CommunityProvider
class CashmereProvider(CommunityProvider):
id = "cashmere"
name = "Cashmere Radio"
homepage = "cashmereradio.com"
brand = "CASHMERE"
tagline = "experimental radio · berlin"
station_source = "cashmereradio.com"
history_source = ""
station_title = "Cashmere Radio"
station_description = "experimental radio from Berlin"
station_genre = "experimental|eclectic"
streams = (
("https://cashmereradio.out.airtime.pro/cashmereradio_a", "ogg", "high", "192"),
("https://cashmereradio.out.airtime.pro/cashmereradio_b", "mp3", "high", "192"),
)
wordmark = "CASHMERE"
hero_lines = ("EXPERIMENTAL RADIO", "BERLIN")
@@ -0,0 +1,24 @@
"""Datafruits: the netlabel's internet radio, relayed by Streampusher."""
from __future__ import annotations
from .base import CommunityProvider
class DatafruitsProvider(CommunityProvider):
id = "datafruits"
name = "Datafruits"
homepage = "datafruits.fm"
brand = "DATAFRUITS"
tagline = "netlabel radio · experimental"
station_source = "streampusher relay + icecast status"
history_source = "icecast title (rolling)"
station_title = "Datafruits"
station_description = "internet radio from the Datafruits netlabel"
station_genre = "electronic|experimental"
streams = (("https://streampusher-relay.club/datafruits.mp3", "mp3", "high", "128"),)
status_url = "https://streampusher-relay.club/status-json.xsl"
wordmark = "DATAFRUITS"
hero_lines = ("NETLABEL RADIO", "EXPERIMENTAL ELECTRONIC")
+108
View File
@@ -0,0 +1,108 @@
"""dublab: Los Angeles community radio, with a public weekly schedule."""
from __future__ import annotations
import contextlib
import time
from datetime import datetime, timezone
from typing import Any
import httpx
from ... import config
from ...models import Broadcast, parse_date
from ..base import fetch_json
from .base import CommunityProvider
SCHEDULE_URL = "https://dublab.com/.netlify/functions/schedule"
def parse_schedule(payload: Any) -> list[Broadcast]:
"""The site's own calendar: ``[{startTime, endTime, summary, ...}]``."""
if not isinstance(payload, list):
return []
broadcasts: list[Broadcast] = []
for entry in payload:
if not isinstance(entry, dict):
continue
name = str(entry.get("summary") or entry.get("title") or "").strip()
start = parse_date(entry.get("startTime") or entry.get("start"))
end = parse_date(entry.get("endTime") or entry.get("end"))
if not name or start is None:
continue
broadcasts.append(Broadcast(name=name, start=start, end=end))
broadcasts.sort(key=lambda item: item.start or datetime.now(timezone.utc))
return broadcasts
class DublabProvider(CommunityProvider):
id = "dublab"
name = "dublab"
homepage = "dublab.com"
brand = "DUBLAB"
tagline = "community radio · los angeles · since 1999"
station_source = "dublab.com schedule"
history_source = "dublab.com schedule (rolling)"
station_title = "dublab"
station_description = "non-profit community radio from Los Angeles, since 1999"
station_genre = "eclectic|experimental"
streams = (("https://dublab.out.airtime.pro/dublab_a", "mp3", "high", "192"),)
# The schedule is the only metadata dublab publishes, so it drives both the
# left pane and "what is on".
has_schedule = True
wordmark = "DUBLAB"
hero_lines = ("LOS ANGELES", "SINCE 1999")
async def fetch_schedule(self, client: httpx.AsyncClient) -> list[Broadcast]:
try:
payload = await fetch_json(client, SCHEDULE_URL, attempts=1)
except RuntimeError:
return self.load_cached_schedule()
entries = self._mark_live(parse_schedule(payload))
if not entries:
return self.load_cached_schedule()
config.write_json(
config.schedule_cache_path(self.id),
{
"fetched": time.time(),
"schedule": [
{
"name": item.name,
"start": item.start.isoformat() if item.start else "",
"end": item.end.isoformat() if item.end else "",
}
for item in entries
],
},
)
return entries
async def fetch_history(self, client: httpx.AsyncClient, station_id: str):
# The schedule is the only "what is on" dublab publishes, so make sure
# we have one before the base class looks for the running show.
if not self.load_cached_schedule():
with contextlib.suppress(Exception):
await self.fetch_schedule(client)
return await super().fetch_history(client, station_id)
def load_cached_schedule(self) -> list[Broadcast]:
raw = config.read_json(config.schedule_cache_path(self.id))
entries = raw.get("schedule") if isinstance(raw, dict) else None
if not isinstance(entries, list):
return []
broadcasts: list[Broadcast] = []
for entry in entries:
if not isinstance(entry, dict):
continue
start = parse_date(entry.get("start"))
name = str(entry.get("name") or "").strip()
if not name or start is None:
continue
broadcasts.append(
Broadcast(name=name, start=start, end=parse_date(entry.get("end")))
)
broadcasts.sort(key=lambda item: item.start or datetime.now(timezone.utc))
return broadcasts
+31
View File
@@ -0,0 +1,31 @@
"""The Lot Radio: live from a shipping container in Brooklyn (HLS)."""
from __future__ import annotations
from .base import CommunityProvider
class LotProvider(CommunityProvider):
id = "lot"
name = "The Lot Radio"
homepage = "thelotradio.com"
brand = "THELOT"
tagline = "live from the lot · brooklyn"
station_source = "thelotradio.com (HLS)"
history_source = ""
station_title = "The Lot Radio"
station_description = "live radio from a shipping container in Brooklyn"
station_genre = "eclectic|house"
# The station streams HLS; mpv plays the playlist directly.
streams = (
(
"https://livepeercdn.studio/hls/85c28sa2o8wppm58/index.m3u8",
"aac",
"high",
"",
),
)
wordmark = "THELOT"
hero_lines = ("BROOKLYN", "LIVE FROM THE LOT")
+23
View File
@@ -0,0 +1,23 @@
"""LYL Radio: independent webradio from Lyon and Paris."""
from __future__ import annotations
from .base import CommunityProvider
class LylProvider(CommunityProvider):
id = "lyl"
name = "LYL Radio"
homepage = "lyl.live"
brand = "LYL"
tagline = "we're the rest · lyon · paris"
station_source = "icecast.lyl.live"
history_source = ""
station_title = "LYL Radio"
station_description = "independent webradio from Lyon and Paris"
station_genre = "eclectic|experimental"
streams = (("https://icecast.lyl.live/live", "mp3", "high", "192"),)
wordmark = "LYL"
hero_lines = ("WE'RE THE REST", "LYON · PARIS")
@@ -0,0 +1,24 @@
"""Refuge Worldwide: Berlin community radio on a radio.co relay."""
from __future__ import annotations
from .base import CommunityProvider
class RefugeProvider(CommunityProvider):
id = "refuge"
name = "Refuge Worldwide"
homepage = "refugeworldwide.com"
brand = "REFUGE"
tagline = "community radio · berlin"
station_source = "radio.co status"
history_source = "radio.co now playing (rolling)"
station_title = "Refuge Worldwide"
station_description = "community radio from Berlin"
station_genre = "eclectic|electronic"
streams = (("https://streaming.radio.co/s3699c5e49/listen", "mp3", "high", "192"),)
radio_co_id = "s3699c5e49"
wordmark = "REFUGE"
hero_lines = ("COMMUNITY RADIO", "BERLIN")
+66
View File
@@ -0,0 +1,66 @@
"""Rinse FM: London's station family, a proxy mount each."""
from __future__ import annotations
from ...models import Station
from .base import CommunityProvider
#: (station id, title, genre, mount, description)
_STATIONS: tuple[tuple[str, str, str, str, str], ...] = (
(
"rinse_uk",
"Rinse UK",
"electronic|house",
"rinse_uk",
"London's underground dance station, on air since 1994",
),
(
"kool",
"Kool London",
"jungle|drum & bass",
"kool",
"Kool London: jungle, drum & bass and roots",
),
(
"swu",
"SWU",
"soul|eclectic",
"swu",
"SWU: soul, funk and everything south west",
),
)
class RinseProvider(CommunityProvider):
id = "rinse"
name = "Rinse FM"
homepage = "rinse.fm"
brand = "RINSE"
tagline = "london · since 1994"
station_source = "rinse.fm streams"
history_source = ""
station_genre = "electronic"
wordmark = "RINSE"
hero_lines = ("LONDON", "SINCE 1994")
def stations(self) -> list[Station]:
return [
Station(
id=station_id,
title=title,
description=description,
genre=genre,
playlists=[
{
"url": f"https://admin.stream.rinse.fm/proxy/{mount}/stream",
"format": "aacp",
"quality": "high",
"bitrate": "128",
}
],
provider=self.id,
)
for station_id, title, genre, mount, description in _STATIONS
]
@@ -0,0 +1,83 @@
"""Radio Browser: a directory of the community's stations rather than a station.
The most-clicked working relays are loaded a page at a time, and the rest of
the 60,000-station directory is searched on the server as you type. There is no
now-playing API, so this catalogue offers breadth, not history: pick a station
and the stream speaks for itself.
"""
from __future__ import annotations
import httpx
from ... import config
from ...models import Song, Station
from ..base import RadioProvider, StationPage
from . import api, fallback
from . import hero as hero_art
__all__ = ["RadioBrowserProvider"]
class RadioBrowserProvider(RadioProvider):
id = "radiobrowser"
name = "Radio Browser"
homepage = "radio-browser.info"
brand = "RADIO BROWSER"
tagline = "community directory · live search"
station_source = "radio-browser.info (paged + search)"
history_source = ""
hero = hero_art.HERO
hero_lines = ("RADIO BROWSER", "COMMUNITY DIRECTORY", "KEYLESS · OPEN DATA")
# A directory has no show schedule and no Bandcamp worth scraping.
bandcamp = False
# The full directory is much larger than the list fetched at startup; the
# app asks the API when the filter changes.
server_search = True
# The directory is loaded a page at a time, in click-count order.
paginated = True
preserve_order = True
@property
def user_agent(self) -> str:
return f"{config.USER_AGENT}; +https://www.radio-browser.info"
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
return await api.fetch_stations(client)
async def fetch_station_page(
self, client: httpx.AsyncClient, offset: int = 0
) -> StationPage:
return await api.fetch_station_page(client, offset)
async def fetch_stations_by_ids(
self, client: httpx.AsyncClient, station_ids: list[str]
) -> list[Station]:
return await api.fetch_stations_by_ids(client, station_ids)
async def search_stations(
self, client: httpx.AsyncClient, query: str
) -> list[Station]:
return await api.search_stations(client, query)
def load_cached_stations(self) -> list[Station]:
return api.load_cached_stations()
def fallback_stations(self) -> list[Station]:
return fallback.fallback_stations()
async def fetch_history(
self, client: httpx.AsyncClient, station_id: str
) -> list[Song]:
# Radio Browser publishes no now-playing data at all.
return []
def load_cached_history(self, station_id: str) -> list[Song]:
return []
def fallback_urls(self, station: Station) -> list[str]:
return [entry["url"] for entry in station.sorted_playlists()]
+238
View File
@@ -0,0 +1,238 @@
"""Radio Browser: the community's own station directory.
``https://de1.api.radio-browser.info/json/stations/search`` is keyless, CORS-open
and public-domain data. It knows nothing about what a station is playing —
there is no per-station history — so this catalogue is a *directory*: the most
clicked working stations, a page at a time. Whatever the relay puts in its
stream is what you hear; the app has no metadata to show.
"""
from __future__ import annotations
import asyncio
import re
from typing import Any
from urllib.parse import urlencode
import httpx
from ... import config
from ...models import Station, to_int
from ..base import StationPage, fetch_json
API_URL = "https://de1.api.radio-browser.info/json/stations/search"
BY_UUID_URL = "https://de1.api.radio-browser.info/json/stations/byuuid"
#: Stations per directory page. The app loads the first page at startup and
#: the next ones as the station list nears its end.
PAGE_SIZE = 100
#: Results per field for a directory search. The four fields are merged.
SEARCH_LIMIT = 100
#: The search endpoint has no full-text parameter, so a free-text query is
#: sent once per field and the answers are merged.
_SEARCH_FIELDS = ("name", "tag", "country", "language")
#: Radio Browser ids are UUIDs; anything else is another catalogue's favourite.
_UUID_PATTERN = re.compile(
r"^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$"
)
#: How many UUIDs to ask for in one ``byuuid`` request.
LOOKUP_BATCH = 50
_PARAMS = {
"hidebroken": "true",
"order": "clickcount",
"reverse": "true",
}
_CODECS = {"mp3": "mp3", "aac": "aac", "aac+": "aacp", "ogg": "ogg", "opus": "opus"}
def _quality(bitrate: int) -> str:
if bitrate >= 192:
return "highest"
if bitrate >= 96:
return "high"
return "low" if bitrate else ""
def station_from_entry(entry: dict[str, Any]) -> Station | None:
if not isinstance(entry, dict):
return None
name = str(entry.get("name") or "").strip()
url = str(entry.get("url_resolved") or entry.get("url") or "").strip()
station_id = str(entry.get("stationuuid") or "").strip()
if not name or not station_id or not url.startswith(("http://", "https://")):
return None
bitrate = to_int(entry.get("bitrate"))
codec = str(entry.get("codec") or "").strip().lower()
tags = [
tag.strip()
for tag in str(entry.get("tags") or "").split(",")
if tag.strip()
][:3]
country = str(entry.get("country") or "").strip()
language = str(entry.get("language") or "").strip()
description = " · ".join(part for part in (country, language) if part)
favicon = str(entry.get("favicon") or "").strip()
return Station(
id=station_id,
title=name,
description=description or "internet radio",
genre="|".join(tags),
image=favicon if favicon.startswith(("http://", "https://")) else "",
playlists=[
{
"url": url,
"format": _CODECS.get(codec, codec),
"quality": _quality(bitrate),
"bitrate": str(bitrate) if bitrate else "",
}
],
provider="radiobrowser",
)
def parse_stations(payload: Any, *, sort: bool = True) -> list[Station]:
if not isinstance(payload, list):
return []
stations: list[Station] = []
seen: set[str] = set()
for entry in payload:
station = station_from_entry(entry)
if station is None or station.id in seen:
continue
seen.add(station.id)
stations.append(station)
if sort:
stations.sort(key=lambda station: station.title.casefold())
return stations
def _page_url(offset: int) -> str:
params = {**_PARAMS, "limit": str(PAGE_SIZE), "offset": str(offset)}
return f"{API_URL}?{urlencode(params)}"
async def fetch_station_page(
client: httpx.AsyncClient, offset: int = 0
) -> StationPage:
"""One page of the directory, most-clicked first."""
try:
payload = await fetch_json(client, _page_url(offset))
except RuntimeError as exc:
raise RuntimeError(f"could not fetch the Radio Browser directory: {exc}") from None
stations = parse_stations(payload, sort=False)
rows = len(payload) if isinstance(payload, list) else 0
next_offset = offset + PAGE_SIZE if rows >= PAGE_SIZE else None
if offset == 0 and stations and isinstance(payload, list):
config.write_json(config.channels_cache_path("radiobrowser"), payload)
return StationPage(stations=stations, next_offset=next_offset)
async def fetch_stations(client: httpx.AsyncClient) -> list[Station]:
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]:
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
@@ -0,0 +1,44 @@
"""A handful of directory entries for a cold offline start.
Radio Browser itself is the directory, but an offline start has no cache — so
this is ten stations whose relays have been up for years, one per taste.
"""
from __future__ import annotations
from ...models import Station
#: (id, title, country, genre tags, url, format, bitrate)
_STATIONS: tuple[tuple[str, str, str, str, str, str, str], ...] = (
("fallback-groovesalad", "SomaFM Groove Salad", "United States", "ambient|chill", "https://ice1.somafm.com/groovesalad-128-mp3", "mp3", "128"),
("fallback-radioparadise", "Radio Paradise", "United States", "eclectic|rock", "https://stream.radioparadise.com/flac", "flac", "1411"),
("fallback-fip", "FIP", "France", "eclectic|jazz", "https://icecast.radiofrance.fr/fip-midfi.mp3", "mp3", "128"),
("fallback-kexp", "KEXP", "United States", "alternative|indie", "https://kexp.streamguys1.com/kexp160.aac", "aac", "160"),
("fallback-nts1", "NTS Radio 1", "United Kingdom", "eclectic|electronic", "https://stream-relay-geo.ntslive.net/stream", "mp3", "128"),
("fallback-rinse", "Rinse UK", "United Kingdom", "house|electronic", "https://admin.stream.rinse.fm/proxy/rinse_uk/stream", "aacp", "128"),
("fallback-dublab", "dublab", "United States", "eclectic|experimental", "https://dublab.out.airtime.pro/dublab_a", "mp3", "192"),
("fallback-cashmere", "Cashmere Radio", "Germany", "experimental", "https://cashmereradio.out.airtime.pro/cashmereradio_a", "ogg", "192"),
("fallback-datafruits", "Datafruits", "United States", "electronic|experimental", "https://streampusher-relay.club/datafruits.mp3", "mp3", "128"),
("fallback-anonradio", "aNONradio", "United States", "eclectic|talk", "https://anonradio.net:8443/anonradio", "mp3", "192"),
)
def fallback_stations() -> list[Station]:
return [
Station(
id=station_id,
title=title,
description=country,
genre=genre,
playlists=[
{
"url": url,
"format": fmt,
"quality": "high",
"bitrate": bitrate,
}
],
provider="radiobrowser",
)
for station_id, title, country, genre, url, fmt, bitrate in _STATIONS
]
@@ -0,0 +1,7 @@
"""Radio Browser's header hero: the directory's own name."""
from __future__ import annotations
from ..wordmark import render
HERO: tuple[str, ...] = render("RADIOBROWSER")
@@ -0,0 +1,136 @@
"""Radio Paradise: five hand-curated mixes, listener-supported since 2000.
Unlike the community stations, Radio Paradise publishes a proper now-playing
API for every mix, with album and cover art — so this provider is a small
catalogue with real track metadata rather than a single relay.
"""
from __future__ import annotations
import re
import time
from datetime import datetime, timezone
from pathlib import Path
import httpx
from ... import config
from ...models import Song, Station
from ..base import RadioProvider
from ..wordmark import render
from . import api
__all__ = ["RadioParadiseProvider"]
#: How many tracks LAST PLAYED remembers per mix.
HISTORY_LIMIT = 20
class RadioParadiseProvider(RadioProvider):
id = "radioparadise"
name = "Radio Paradise"
homepage = "radioparadise.com"
brand = "RADIO PARADISE"
tagline = "listener-supported · curated mixes"
station_source = "api.radioparadise.com/now_playing"
history_source = "api.radioparadise.com (rolling)"
hero = render("PARADISE")
hero_lines = ("RADIO PARADISE", "LISTENER SUPPORTED", "PARADISE, CALIFORNIA")
@property
def user_agent(self) -> str:
return f"{config.USER_AGENT}; +https://radioparadise.com"
def stations(self) -> list[Station]:
return [
Station(
id=station_id,
title=title,
description=f"Radio Paradise — {title}",
genre=genre,
playlists=api.playlists(names),
provider=self.id,
)
for station_id, _chan, title, genre, names in api.CHANNELS
]
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
return self.stations()
def load_cached_stations(self) -> list[Station]:
return self.stations()
def fallback_stations(self) -> list[Station]:
return self.stations()
def _songs_cache(self, station_id: str) -> Path:
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", station_id or self.id)
return config.SONGS_CACHE_DIR / f"{self.id}-{safe}.json"
async def fetch_history(
self, client: httpx.AsyncClient, station_id: str
) -> list[Song]:
chan = api.CHANNEL_BY_STATION.get(station_id)
if chan is None:
return self.load_cached_history(station_id)
current = await api.fetch_now(client, chan)
if current is None:
return self.load_cached_history(station_id)
history = self.load_cached_history(station_id)
if history and history[0].key() == current.key():
current.played_at = history[0].played_at
history = [current, *history[1:]]
else:
current.played_at = datetime.now(timezone.utc)
history = [current, *[song for song in history if song.key() != current.key()]]
history = history[:HISTORY_LIMIT]
config.write_json(
self._songs_cache(station_id),
{
"fetched": time.time(),
"songs": [
{
"title": song.title,
"artist": song.artist,
"album": song.album,
"art": song.art_url,
"played_at": song.played_at.isoformat()
if song.played_at
else "",
}
for song in history
],
},
)
return history
def load_cached_history(self, station_id: str) -> list[Song]:
raw = config.read_json(self._songs_cache(station_id))
entries = raw.get("songs") if isinstance(raw, dict) else None
if not isinstance(entries, list):
return []
songs: list[Song] = []
for entry in entries:
if not isinstance(entry, dict):
continue
song = Song(
title=str(entry.get("title") or "").strip(),
artist=str(entry.get("artist") or "").strip(),
album=str(entry.get("album") or "").strip(),
art_url=str(entry.get("art") or "").strip(),
played_at=None,
)
if isinstance(entry.get("played_at"), str) and entry["played_at"]:
from ...models import parse_date
song.played_at = parse_date(entry["played_at"])
if song.title or song.artist:
songs.append(song)
return songs
def fallback_urls(self, station: Station) -> list[str]:
return [entry["url"] for entry in station.sorted_playlists()]
@@ -0,0 +1,85 @@
"""Radio Paradise's public endpoints, and nothing but.
* ``https://api.radioparadise.com/api/now_playing?chan=N`` -> the current track
(artist, title, album, year, cover art, seconds remaining)
* ``https://stream.radioparadise.com/<name>`` -> the icecast relays
Radio Paradise curates five mixes; each is one relay and one channel number on
the now-playing API. The main mix carries lossy relays as well as FLAC; the
alternate mixes are FLAC only.
"""
from __future__ import annotations
from typing import Any
import httpx
from ...models import Song
from ..base import fetch_json
NOW_URL = "https://api.radioparadise.com/api/now_playing?chan={chan}"
STREAM_URL = "https://stream.radioparadise.com/{name}"
FORMAT_LABELS = {"flac": ("flac", "highest"), "aac-320": ("aac", "high")}
#: (station id, channel, title, genre, relay names)
CHANNELS: tuple[tuple[str, int, str, str, tuple[str, ...]], ...] = (
(
"main",
0,
"The Main Mix",
"eclectic|rock",
("flac", "aac-320", "mp3-320", "mp3-128"),
),
("mellow", 1, "Mellow Mix", "chill|eclectic", ("mellow-flac",)),
("rock", 2, "RockIt!", "rock|alternative", ("rock-flac",)),
("global", 3, "The Globe", "world|eclectic", ("global-flac",)),
("beyond", 5, "Beyond...", "ambient|experimental", ("beyond-flac",)),
)
CHANNEL_BY_STATION = {station_id: chan for station_id, chan, *_ in CHANNELS}
def parse_now(payload: Any) -> Song | None:
if not isinstance(payload, dict):
return None
artist = str(payload.get("artist") or "").strip()
title = str(payload.get("title") or "").strip()
if not (artist or title):
return None
return Song(
artist=artist,
title=title,
album=str(payload.get("album") or "").strip(),
art_url=str(payload.get("cover") or payload.get("cover_med") or "").strip(),
)
async def fetch_now(client: httpx.AsyncClient, chan: int) -> Song | None:
try:
payload = await fetch_json(client, NOW_URL.format(chan=chan), attempts=1)
except RuntimeError:
return None
return parse_now(payload)
def playlists(names: tuple[str, ...]) -> list[dict[str, str]]:
"""The relay list for one mix, best first."""
entries: list[dict[str, str]] = []
for name in names:
fmt, quality = FORMAT_LABELS.get(name, (name.split("-")[0], "high"))
bitrate = ""
if fmt == "flac":
bitrate = "1411" # nominal CD-quality FLAC rate
elif name.startswith(("aac-", "mp3-")):
bitrate = name.split("-", 1)[1]
entries.append(
{
"url": STREAM_URL.format(name=name),
"format": fmt,
"quality": quality,
"bitrate": bitrate,
}
)
return entries
@@ -0,0 +1,7 @@
"""Radio Paradise's header hero: the one word it is named after."""
from __future__ import annotations
from ..wordmark import render
HERO: tuple[str, ...] = render("PARADISE")
@@ -15,6 +15,7 @@ from ... import config
from ...models import Song, Station
from ..base import RadioProvider
from . import api, fallback
from . import hero as hero_art
__all__ = ["SomaFMProvider"]
@@ -28,6 +29,10 @@ class SomaFMProvider(RadioProvider):
station_source = "somafm.com/channels.json"
history_source = "songs/<station>.json"
hero = hero_art.LOGO
hero_gradient = True
hero_lines = hero_art.TAGLINES
@property
def user_agent(self) -> str:
# SomaFM asks clients to identify themselves, so say who we are and
+19
View File
@@ -0,0 +1,19 @@
"""SomaFM's header hero: the block-letter wordmark and the three lines beside it.
The glyphs come from the shared half-block font
(:mod:`beefy_player.providers.wordmark`); the header paints them with the app's
spectrum gradient (``hero_gradient``), so the logo takes on the terminal's own
colours instead of carrying RGB values around.
"""
from __future__ import annotations
from ..wordmark import render
LOGO: tuple[str, ...] = render("SOMAFM")
TAGLINES: tuple[str, ...] = (
"SOMA FM RADIO",
"LISTENER SUPPORTED",
"SAN FRANCISCO",
)
@@ -0,0 +1,76 @@
"""tilderadio: the tildeverse's community station.
One stream, one catalogue entry, a live DJ when a show is on and the shared
library rotation the rest of the time. Everything tilderadio-specific — the
``/api/now/`` schema, the ``now_playing`` + ``history[]`` pairing, the AzuraCast
mount list and the bundled fallback — lives in this package; the app only sees
:class:`TilderadioProvider`.
"""
from __future__ import annotations
import httpx
from ... import config
from ...models import Broadcast, Song, Station
from ..base import RadioProvider
from . import api, fallback
from . import hero as hero_art
__all__ = ["TilderadioProvider"]
class TilderadioProvider(RadioProvider):
id = "tilderadio"
name = "tilderadio"
homepage = "tilderadio.org"
brand = "TILDE RADIO"
tagline = "community radio · tildeverse"
station_source = "tilderadio.org/api/now/"
history_source = "now_playing + history[]"
# The station's own radio icon, converted to half blocks by
# ``tools/ansi_art.py``; the caption is the tildeverse's motto.
hero = hero_art.HERO
hero_lines = (
"TILDERADIO",
"STREAMED BY / FOR USERS OF THE",
"TILDEVERSE",
)
# One stream, so the left pane is a schedule instead of a station list.
has_schedule = True
# A shared library rotation is not a Bandcamp catalogue; skip the lookups.
bandcamp = False
@property
def user_agent(self) -> str:
# A small volunteer station; introduce ourselves and say where we are.
return f"{config.USER_AGENT}; +https://tilderadio.org"
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
return [await api.fetch_station(client)]
def load_cached_stations(self) -> list[Station]:
return api.load_cached_station()
def fallback_stations(self) -> list[Station]:
return fallback.fallback_stations()
async def fetch_history(
self, client: httpx.AsyncClient, station_id: str
) -> list[Song]:
return await api.fetch_songs(client, station_id)
def load_cached_history(self, station_id: str) -> list[Song]:
return api.load_cached_songs(station_id)
async def fetch_schedule(
self, client: httpx.AsyncClient
) -> list[Broadcast]:
return await api.fetch_schedule(client)
def load_cached_schedule(self) -> list[Broadcast]:
return api.load_cached_schedule()
def fallback_urls(self, station: Station) -> list[str]:
return fallback.known_stream_urls()
+401
View File
@@ -0,0 +1,401 @@
"""tilderadio's public endpoints, and nothing but.
* ``https://tilderadio.org/api/now/`` -> the station, listener count, the live
DJ, the current track and the last handful of tracks
* ``https://azuracast.tilderadio.org/api/nowplaying/tilderadio`` -> the mounts
(mp3/ogg, bitrates) behind the stream, so we can offer the best one first
tilderadio is a single-station catalogue: one community stream for the whole
tildeverse, run on an AzuraCast instance. That makes the "station list" a list
of one, and it means ``/now/`` is both the catalogue *and* the history feed —
the current track arrives as ``now_playing``, the previous ones as
``history[]``. Everything here is forgiving: the station is better served by a
slightly degraded panel than by a crash.
"""
from __future__ import annotations
import re
import time
from collections.abc import Iterable
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import httpx
from ... import config
from ...models import (
Broadcast,
Song,
Station,
parse_date,
parse_last_playing,
to_int,
)
from ..base import fetch_json, quote_station_id
PROVIDER_ID = "tilderadio"
API_ROOT = "https://tilderadio.org/api"
NOW_URL = f"{API_ROOT}/now/"
SCHEDULE_URL = f"{API_ROOT}/schedule/"
AZURACAST_URL = "https://azuracast.tilderadio.org/api/nowplaying/{station}"
DEFAULT_STATION_ID = "tilderadio"
STATION_IMAGE = "https://tilderadio.org/logos/tilderadio.png"
DESCRIPTION = "community radio for the tildeverse"
GENRE = "community|eclectic"
# --------------------------------------------------------------------------- #
# payload -> Station / Song
# --------------------------------------------------------------------------- #
def _artist_title(entry: dict[str, Any]) -> tuple[str, str]:
"""AzuraCast sends ``artist``/``title`` *or* one ``"Artist - Title"`` blob."""
artist = str(entry.get("artist") or "").strip()
title = str(entry.get("title") or "").strip()
if not artist:
text = str(entry.get("text") or "").strip()
split = parse_last_playing(text) if text else None
if split is not None:
artist, title = split.artist or artist, split.title or title
return artist, title
def song_from_track(entry: Any) -> Song | None:
if not isinstance(entry, dict):
return None
artist, title = _artist_title(entry)
if not (artist or title):
return None
return Song(
title=title,
artist=artist,
album=str(entry.get("album") or "").strip(),
art_url=str(entry.get("art") or entry.get("albumArt") or "").strip(),
played_at=parse_date(entry.get("played_at") or entry.get("date")),
)
def parse_tracks(entries: Any) -> list[Song]:
"""Newest first, and the same track only once even if the feed repeats it."""
if not isinstance(entries, list):
return []
songs: list[Song] = []
seen: set[tuple[str, Any]] = set()
for entry in entries:
song = song_from_track(entry)
if song is None:
continue
marker = (song.key(), song.played_at)
if marker in seen:
continue
seen.add(marker)
songs.append(song)
if all(song.played_at for song in songs):
songs.sort(key=lambda song: song.played_at, reverse=True) # type: ignore[arg-type,return-value]
return songs
def songs_from_now(payload: Any) -> list[Song]:
"""``now_playing`` followed by ``history[]``, as one newest-first list."""
if not isinstance(payload, dict):
return []
entries: list[Any] = [payload.get("now_playing")]
history = payload.get("history")
if isinstance(history, list):
entries.extend(history)
return parse_tracks(entries)
def _quality(bitrate: int) -> str:
if bitrate >= 256:
return "highest"
if bitrate >= 128:
return "high"
return "low" if bitrate else ""
def playlists_from_mounts(payload: Any) -> list[dict[str, str]]:
"""AzuraCast's mount list -> the playlist dicts :class:`Station` speaks."""
if not isinstance(payload, dict):
return []
info = payload.get("station")
mounts = info.get("mounts") if isinstance(info, dict) else None
if not isinstance(mounts, list):
return []
playlists: list[dict[str, str]] = []
seen: set[str] = set()
for mount in mounts:
if not isinstance(mount, dict):
continue
url = mount.get("url")
if not isinstance(url, str) or not url.startswith(("http://", "https://")):
continue
if url in seen:
continue
seen.add(url)
bitrate = to_int(mount.get("bitrate"))
playlists.append(
{
"url": url,
"format": str(mount.get("format") or "").strip().lower(),
"quality": _quality(bitrate),
"bitrate": str(bitrate) if bitrate else "",
}
)
return playlists
def _playlists_from_listen_url(info: dict[str, Any]) -> list[dict[str, str]]:
url = info.get("listen_url")
if not isinstance(url, str) or not url.startswith(("http://", "https://")):
return []
return [{"url": url, "format": "ogg", "quality": "high", "bitrate": ""}]
def _is_live(payload: dict[str, Any]) -> bool:
live = payload.get("live")
if isinstance(live, dict):
return bool(live.get("is_live"))
return bool(payload.get("is_live"))
def _live_dj(payload: dict[str, Any]) -> str:
"""Only name a DJ when the station says someone is actually on air."""
if not _is_live(payload):
return ""
live = payload.get("live")
if isinstance(live, dict):
return str(live.get("name") or live.get("dj") or "").strip()
return str(payload.get("dj") or "").strip()
def station_from_now(
payload: Any, playlists: Iterable[Any] = ()
) -> Station | None:
"""The whole catalogue, from one ``/api/now/`` response."""
if not isinstance(payload, dict):
return None
info = payload.get("station")
if not isinstance(info, dict):
return None
station_id = (
str(info.get("shortcode") or info.get("name") or DEFAULT_STATION_ID).strip()
or DEFAULT_STATION_ID
)
known = [dict(entry) for entry in playlists if isinstance(entry, dict)]
station = Station(
id=station_id,
title=str(info.get("name") or station_id).strip() or DEFAULT_STATION_ID,
description=str(info.get("description") or "").strip() or DESCRIPTION,
genre=GENRE,
dj=_live_dj(payload),
presenter="" if _is_live(payload) else "auto dj · library rotation",
image=STATION_IMAGE,
listeners=to_int(payload.get("listeners")),
playlists=known or _playlists_from_listen_url(info),
provider=PROVIDER_ID,
)
current = payload.get("now_playing")
if isinstance(current, dict):
song = song_from_track(current)
if song is not None:
station.now_playing = song
station.last_playing = str(current.get("text") or "").strip() or (
f"{song.artist} - {song.title}" if song.artist else song.title
)
return station
# --------------------------------------------------------------------------- #
# schedule
# --------------------------------------------------------------------------- #
def parse_schedule(payload: Any) -> list[Broadcast]:
"""``/api/schedule/`` -> broadcasts, soonest first."""
entries = payload.get("schedule") if isinstance(payload, dict) else None
if not isinstance(entries, list):
return []
broadcasts: list[Broadcast] = []
for entry in entries:
if not isinstance(entry, dict):
continue
name = str(entry.get("name") or entry.get("slug") or "").strip()
start = parse_date(entry.get("start_ts") or entry.get("start"))
end = parse_date(entry.get("end_ts") or entry.get("end"))
if not name or start is None:
continue
broadcasts.append(Broadcast(name=name, dj=name, start=start, end=end))
broadcasts.sort(key=lambda item: item.start or datetime.now(timezone.utc))
return broadcasts
def live_broadcast(payload: Any) -> Broadcast | None:
"""The show that is on air right now, if the feed says there is one."""
if not isinstance(payload, dict) or not payload.get("is_live"):
return None
name = _live_dj(payload) or "live broadcast"
return Broadcast(
name=name,
dj=name,
start=parse_date(payload.get("broadcast_start")),
end=parse_date(payload.get("broadcast_end")),
live=True,
)
# --------------------------------------------------------------------------- #
# caching
# --------------------------------------------------------------------------- #
def station_cache_path() -> Path:
return config.channels_cache_path(PROVIDER_ID)
def _songs_cache_path(station_id: str) -> Path:
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", station_id or DEFAULT_STATION_ID)
return config.SONGS_CACHE_DIR / f"{PROVIDER_ID}-{safe}.json"
def _songs_to_cache(songs: Iterable[Song]) -> list[dict[str, Any]]:
return [
{
"title": song.title,
"artist": song.artist,
"album": song.album,
"art": song.art_url,
"played_at": song.played_at.isoformat() if song.played_at else "",
}
for song in songs
]
# --------------------------------------------------------------------------- #
# fetching
# --------------------------------------------------------------------------- #
async def _fetch_mounts(
client: httpx.AsyncClient, station_id: str
) -> list[dict[str, str]]:
url = AZURACAST_URL.format(station=quote_station_id(station_id))
try:
payload = await fetch_json(client, url, attempts=1)
except RuntimeError:
return []
return playlists_from_mounts(payload)
async def fetch_station(client: httpx.AsyncClient) -> Station:
try:
payload = await fetch_json(client, NOW_URL)
except RuntimeError as exc:
raise RuntimeError(f"could not fetch tilderadio now-playing: {exc}") from None
station = station_from_now(payload)
if station is None:
raise RuntimeError("tilderadio returned no station data")
# Bitrates and the mp3 mount only come from AzuraCast; losing them is not
# a reason to lose the station.
playlists = await _fetch_mounts(client, station.id)
if playlists:
station.playlists = playlists
config.write_json(
station_cache_path(),
{"fetched": time.time(), "now": payload, "mounts": station.playlists},
)
return station
def load_cached_station() -> list[Station]:
raw = config.read_json(station_cache_path())
if not isinstance(raw, dict):
return []
mounts = raw.get("mounts")
station = station_from_now(
raw.get("now"), mounts if isinstance(mounts, list) else []
)
return [station] if station is not None else []
async def fetch_songs(client: httpx.AsyncClient, station_id: str) -> list[Song]:
try:
payload = await fetch_json(client, NOW_URL, attempts=1)
except RuntimeError:
return load_cached_songs(station_id)
songs = songs_from_now(payload)
if not songs:
return load_cached_songs(station_id)
config.write_json(
_songs_cache_path(station_id),
{"fetched": time.time(), "songs": _songs_to_cache(songs)},
)
return songs
def load_cached_songs(station_id: str) -> list[Song]:
raw = config.read_json(_songs_cache_path(station_id))
if isinstance(raw, dict):
return parse_tracks(raw.get("songs"))
return []
def _schedule_cache_path() -> Path:
return config.schedule_cache_path(PROVIDER_ID)
def load_cached_schedule() -> list[Broadcast]:
raw = config.read_json(_schedule_cache_path())
entries = raw.get("schedule") if isinstance(raw, dict) else None
if not isinstance(entries, list):
return []
return parse_schedule({"schedule": entries})
async def fetch_schedule(client: httpx.AsyncClient) -> list[Broadcast]:
"""What is on air now (if anything) followed by what is coming up."""
live: Broadcast | None = None
try:
now_payload = await fetch_json(client, NOW_URL, attempts=1)
except RuntimeError:
now_payload = None
live = live_broadcast(now_payload)
try:
payload = await fetch_json(client, SCHEDULE_URL, attempts=1)
except RuntimeError:
upcoming = load_cached_schedule()
else:
upcoming = parse_schedule(payload)
config.write_json(
_schedule_cache_path(),
{
"fetched": time.time(),
"schedule": [
{
"name": item.name,
"start": item.start.isoformat() if item.start else "",
"end": item.end.isoformat() if item.end else "",
}
for item in upcoming
],
},
)
now = datetime.now(timezone.utc)
upcoming = [
item
for item in upcoming
if not item.live and (item.end or item.start or now) >= now
]
return ([live] if live is not None else []) + upcoming
@@ -0,0 +1,56 @@
"""Bundled station, so a cold offline start still makes sense.
tilderadio is a single-station catalogue — one community stream for the whole
tildeverse — so "the bundled station list" is that one station. The mounts
below are the ones the AzuraCast instance has served for years; the live list
from ``/api/now/`` always wins, and the URLs are additionally probed by the
player's normal failover.
"""
from __future__ import annotations
from typing import Any
# (url, format, quality, bitrate)
_MOUNTS: tuple[tuple[str, str, str, str], ...] = (
("https://azuracast.tilderadio.org/listen/tilderadio/radio.mp3", "mp3", "high", "192"),
("https://azuracast.tilderadio.org/listen/tilderadio/320k.mp3", "mp3", "highest", "320"),
("https://azuracast.tilderadio.org/listen/tilderadio/radio.ogg", "ogg", "high", "192"),
)
def fallback_payload() -> dict[str, Any]:
"""A ``/api/now/``-shaped payload, so the normal parser can eat it."""
return {
"api_version": 1,
"status": "ok",
"station": {
"id": 1,
"name": "tilderadio",
"shortcode": "tilderadio",
"listen_url": _MOUNTS[2][0],
},
"is_live": False,
"dj": None,
"listeners": 0,
"now_playing": None,
"history": [],
}
def fallback_playlists() -> list[dict[str, str]]:
return [
{"url": url, "format": fmt, "quality": quality, "bitrate": bitrate}
for url, fmt, quality, bitrate in _MOUNTS
]
def known_stream_urls() -> list[str]:
return [url for url, _format, _quality, _bitrate in _MOUNTS]
def fallback_stations() -> list[Any]:
from .api import station_from_now
station = station_from_now(fallback_payload(), fallback_playlists())
return [station] if station is not None else []
+11
View File
@@ -0,0 +1,11 @@
"""tilderadio's header hero: its name in the shared half-block font.
No colours of its own — the wordmark inherits the terminal's default
foreground, so it looks like part of the chrome rather than a logo pasted in.
"""
from __future__ import annotations
from ..wordmark import render
HERO: tuple[str, ...] = render("TILDERADIO")
+100
View File
@@ -0,0 +1,100 @@
"""A half-block wordmark font for the header hero.
Every shipped catalogue draws its brand the same way: four-column capitals,
six pixels tall, emitted as three rows of half blocks (``▀``/``▄``/``█``) — the
same shape as the original SomaFM logo. Catalogues differ only in the word
they spell and the ANSI colours they paint it with, so the font lives here and
each provider's ``hero.py`` is one line of data.
Glyphs are 6x4 bitmaps (``M`` is 6x5). Two pixel rows become one text row, so
a wordmark is always three rows tall and the header keeps a fixed height.
"""
from __future__ import annotations
from collections.abc import Sequence
#: 6-row bitmap per capital; all rows of one glyph are the same width.
GLYPHS: dict[str, tuple[str, ...]] = {
"A": (".##.", "#..#", "####", "#..#", "#..#", "#..#"),
"B": ("###.", "#..#", "###.", "#..#", "#..#", "###."),
"C": (".##.", "#..#", "#...", "#...", "#..#", ".##."),
"D": ("###.", "#..#", "#..#", "#..#", "#..#", "###."),
"E": ("####", "#...", "###.", "#...", "#...", "####"),
"F": ("####", "#...", "###.", "#...", "#...", "#..."),
"G": (".##.", "#..#", "#...", "#.##", "#..#", ".##."),
"H": ("#..#", "#..#", "####", "#..#", "#..#", "#..#"),
"I": ("###", ".#.", ".#.", ".#.", ".#.", "###"),
"K": ("#..#", "#.#.", "##..", "##..", "#.#.", "#..#"),
"L": ("#...", "#...", "#...", "#...", "#...", "####"),
# 5 wide on purpose: the M is the odd one out in the original wordmark.
"M": ("#...#", "##.##", "#.#.#", "#.#.#", "#...#", "#...#"),
"N": ("#..#", "##.#", "##.#", "#.##", "#..#", "#..#"),
"O": (".##.", "#..#", "#..#", "#..#", "#..#", ".##."),
"P": ("###.", "#..#", "###.", "#...", "#...", "#..."),
"R": ("###.", "#..#", "###.", "#.#.", "#..#", "#..#"),
"S": (".###", "#...", "###.", "...#", "...#", "###."),
"T": ("###", ".#.", ".#.", ".#.", ".#.", ".#."),
"U": ("#..#", "#..#", "#..#", "#..#", "#..#", ".##."),
"V": ("#..#", "#..#", "#..#", "#..#", ".##.", ".##."),
"W": ("#...#", "#...#", "#.#.#", "#.#.#", "##.##", "#...#"),
"Y": ("#..#", "#..#", ".##.", "..#.", "..#.", "..#."),
}
ROWS = 3
_GAP = " "
def _cell(top: str, bottom: str) -> str:
if top == "#" and bottom == "#":
return "█"
if top == "#":
return "▀"
if bottom == "#":
return "▄"
return " "
def _glyph_rows(char: str) -> tuple[str, ...]:
bitmap = GLYPHS.get(char)
if bitmap is None:
raise KeyError(f"no wordmark glyph for {char!r}")
return tuple(
"".join(_cell(bitmap[row * 2][col], bitmap[row * 2 + 1][col]) for col in range(len(bitmap[0])))
for row in range(ROWS)
)
def render(text: str, styles: Sequence[str | None] | None = None) -> tuple[str, ...]:
"""Spell ``text`` as three rows of half blocks.
``styles`` gives one rich-markup style per character (``None`` leaves it in
the terminal's default colour). Contiguous runs of the same style share a
markup span, so ``render("BASSDRIVE", ...)`` paints the first four letters
and the last five separately.
"""
if styles is not None and len(styles) != len(text):
raise ValueError("styles must provide one entry per character")
glyphs = [_glyph_rows(char.upper()) for char in text]
rows: list[str] = []
for row in range(ROWS):
cells: list[tuple[str | None, str]] = []
for index, glyph in enumerate(glyphs):
style = styles[index] if styles is not None else None
if index:
cells.append((style, _GAP))
cells.extend((style, char) for char in glyph[row])
while cells and cells[-1][1] == " ":
cells.pop()
segments: list[tuple[str | None, str]] = []
for style, char in cells:
if segments and segments[-1][0] == style:
segments[-1] = (style, segments[-1][1] + char)
else:
segments.append((style, char))
rows.append(
"".join(f"[{style}]{body}[/]" if style else body for style, body in segments)
)
return tuple(rows)
+62 -10
View File
@@ -76,9 +76,14 @@ StationList {
color: ansi_default;
width: 1fr;
height: 1fr;
border: round ansi_bright_black;
border-title-color: ansi_default;
padding: 0 1;
background: ansi_default;
}
SchedulePanel {
color: ansi_default;
width: 1fr;
height: 1fr;
display: none;
background: ansi_default;
}
@@ -86,8 +91,30 @@ StagePanel {
color: ansi_default;
layout: vertical;
width: 1fr;
border: round ansi_bright_black;
border-title-color: ansi_default;
background: ansi_default;
}
#stage_frame_top,
#stage_frame_bottom {
height: 1;
background: ansi_default;
}
#stage_middle {
height: 1fr;
background: ansi_default;
}
#stage_frame_left,
#stage_frame_right {
width: 1;
height: 1fr;
background: ansi_default;
}
#stage_body {
width: 1fr;
height: 1fr;
padding: 0 1;
background: ansi_default;
}
@@ -132,9 +159,6 @@ SpectrumPanel {
color: ansi_default;
height: 2fr;
min-height: 6;
border: round ansi_bright_black;
border-title-color: ansi_default;
padding: 0 1;
background: ansi_default;
}
@@ -151,12 +175,40 @@ ModalScreen {
background: ansi_default;
}
#help {
#providers {
color: ansi_default;
width: 78;
width: 66;
max-width: 100%;
height: auto;
max-height: 100%;
border: round ansi_bright_black;
border-title-color: ansi_default;
padding: 1 2;
overflow-y: auto;
scrollbar-size-vertical: 1;
scrollbar-color: ansi_bright_black;
scrollbar-background: ansi_default;
background: ansi_default;
}
#providers-body {
color: ansi_default;
height: auto;
background: ansi_default;
}
#help {
color: ansi_default;
width: 78;
max-width: 100%;
height: auto;
max-height: 100%;
border: round ansi_bright_black;
border-title-color: ansi_default;
padding: 1 2;
overflow-y: auto;
scrollbar-size-vertical: 1;
scrollbar-color: ansi_bright_black;
scrollbar-background: ansi_default;
background: ansi_default;
}
+27 -2
View File
@@ -22,7 +22,7 @@ from textual.strip import Strip
from textual.widget import Widget
from .. import artwork
from .common import art_lines
from .common import active_stops, art_lines
_NULL_STYLE = Style()
_NULL_CONTROL = ((ControlType.CURSOR_FORWARD, 0),)
@@ -308,6 +308,8 @@ class ArtPane(Widget):
self._last_data: bytes | None = None
self._last_size: tuple[int, int] = (0, 0)
self._mosaic_key = ""
#: True while a modal is up: the inline image would sit over the menu.
self._suspended = False
# ------------------------------------------------------------------ #
# mosaic background
@@ -320,7 +322,12 @@ class ArtPane(Widget):
return Text("")
width = max(1, self.size.width)
height = max(1, self.size.height)
return Group(*art_lines(_art_key(app), width, height))
if artwork.supported() and app.artwork_bytes is not None:
# A real image occupies this box (letterboxed, so not all of it).
# Leave the space blank: the patterned mosaic behind a scaled
# picture reads as noise around its edges.
return Group(*[Text(" " * width) for _ in range(height)])
return Group(*art_lines(_art_key(app), width, height, active_stops(app)))
# ------------------------------------------------------------------ #
# image overlay
@@ -337,9 +344,27 @@ class ArtPane(Widget):
def on_unmount(self) -> None:
self._drop_image_widget()
def suspend(self) -> None:
"""Take the inline image down so a modal is not drawn underneath it.
Kitty and iTerm2 paint images into cells the terminal owns; Textual
repainting the menu over them does not remove them, so the artwork has
to be deleted and re-emitted rather than covered.
"""
self._suspended = True
self._drop_image_widget()
self.refresh(layout=True)
def resume(self) -> None:
"""Put the artwork back after the modal closes."""
self._suspended = False
self.sync_image(force=True)
def sync_image(self, *, force: bool = False) -> None:
# Keep the mosaic behind the image in sync with the current track.
self.refresh()
if self._suspended:
return
app = cast("BeefyPlayerApp", self.app)
station = app.current
+12 -13
View File
@@ -19,23 +19,20 @@ from zoneinfo import ZoneInfo
from rich.cells import cell_len
from rich.text import Text
from ..palettes import (
DEFAULT_PALETTE,
palette_stops,
)
LOCALTIME_PATH = "/etc/localtime"
_UTC_NAMES = {"UTC", "UCT", "GMT", "ETC/UTC", "ETC/UCT", "ETC/GMT", "Z"}
LEVELS = " ▁▂▃▄▅▆▇█"
# Bottom-to-top spectrum colours, picked from the terminal's stock ANSI palette.
SPECTRUM_STOPS = (
"cyan",
"blue",
"magenta",
"bright_magenta",
"yellow",
"bright_yellow",
"red",
)
def active_stops(app: object) -> tuple[str, ...]:
"""The colour ladder the app is currently drawing with."""
return palette_stops(getattr(app, "palette", DEFAULT_PALETTE))
ART_STOPS = ("blue", "magenta", "bright_magenta", "yellow", "cyan", "green")
STATE_STYLE = {
"playing": ("●", "green"),
@@ -182,7 +179,9 @@ def state_glyph(state: str) -> tuple[str, str]:
return STATE_STYLE.get(state, STATE_STYLE["stopped"])
def art_lines(key: str, width: int, height: int) -> list[Text]:
def art_lines(
key: str, width: int, height: int, stops: tuple[str, ...]
) -> list[Text]:
"""Procedural album art as a solid ANSI-colour mosaic.
Filled block glyphs are used (never cell backgrounds), so the cover is
@@ -200,7 +199,7 @@ def art_lines(key: str, width: int, height: int) -> list[Text]:
nx = column / max(1, width - 1)
wave = 0.5 + 0.5 * math.sin(nx * 2.9 + ny * 2.1 + (seed % 11))
position = (nx * 0.55 + (1.0 - ny) * 0.30 + wave * 0.15) % 1.0
colour = gradient(ART_STOPS, position)
colour = gradient(stops, position)
# Mostly solid blocks with a little texture, like a tiny cover.
glyph = "█" if ((seed + column * 7 + row * 11) % 17) not in (0, 5) else "▓"
line.append(glyph, style=colour)
+199
View File
@@ -0,0 +1,199 @@
"""Pane frames: one grey box whose corners glow in the active palette.
The panes draw their own frames instead of using Textual's ``border`` so the
corners can be coloured. Two of them — the top right and the bottom left —
run a short fade out of the palette's hot end and back into the border grey,
brightest colour at the corner; everything else, including the other two
corners, is that grey.
The ladder is whatever palette the app is on (``c`` cycles them), which is why
every function here takes ``stops`` rather than reading a module constant.
"""
from __future__ import annotations
from functools import cache
from rich.cells import cell_len
from rich.console import Group, RenderableType
from rich.text import Text
from textual.widget import Widget
from ..palettes import DEFAULT_PALETTE, PALETTES, brightness
from .common import active_stops
#: The frame itself: one grey, the same grey as the scrollbar thumb.
EDGE = "bright_black"
#: How many cells a corner fade reaches into the edge.
FADE_CELLS = 4
@cache
def fade_ramp(stops: tuple[str, ...]) -> tuple[str, ...]:
"""The corner ramp: brightest cell first, border grey past its end.
The colours come from the palette's hot end, but a ladder is a hue walk,
not a brightness walk: ``spectrum`` ends on ``red``, which is dimmer than
the ``yellow`` just before it, so painting the ladder backwards put the
dim colour at the corner and the glow out on the edge. Sorting the hot
end by brightness keeps the corner the brightest cell and dims outwards,
whatever theme sits behind the names.
"""
hot = [name for name in stops if name != EDGE][-FADE_CELLS:]
return tuple(sorted(hot, key=brightness, reverse=True))
def corner_fade(distance: int, stops: tuple[str, ...] = ()) -> str:
"""Colour ``distance`` cells out from a corner; 0 is the corner glyph.
The fade is short — four cells — and runs from the brightest colour the
palette's hot end offers back into the plain border grey.
"""
if not stops:
stops = PALETTES[DEFAULT_PALETTE]
ramp = fade_ramp(tuple(stops))
distance = max(0, distance)
return ramp[distance] if distance < len(ramp) else EDGE
def inner_size(width: int, height: int) -> tuple[int, int]:
"""The content box inside the frame."""
return max(1, width - 2), max(1, height - 2)
def _edge_cells(width: int, faded_end: str, stops: tuple[str, ...]) -> list[tuple[str, str]]:
"""Inner cells of an edge; only one end of it fades into a corner."""
inner = max(0, width - 2)
if faded_end == "left":
return [("─", corner_fade(column + 1, stops)) for column in range(inner)]
return [("─", corner_fade(inner - column, stops)) for column in range(inner)]
def _paint(line: Text, cells: list[tuple[str, str]]) -> None:
"""Append cells, grouping neighbours that share a style into one span."""
style: str | None = None
run: list[str] = []
for char, cell_style in cells:
if cell_style != style:
if run:
line.append("".join(run), style=style)
run, style = [], cell_style
run.append(char)
if run:
line.append("".join(run), style=style)
def top(width: int, title: str = "", stops: tuple[str, ...] = ()) -> Text:
width = max(2, width)
cells = _edge_cells(width, "right", stops) # only the top-right corner fades
if title:
label = f" {title} "
start = 1
if start + cell_len(label) <= len(cells):
for offset, char in enumerate(label):
cells[start + offset] = (char, "bold")
line = Text(no_wrap=True)
line.append("╭", style=EDGE)
_paint(line, cells)
line.append("╮", style=f"bold {corner_fade(0, stops)}")
return line
def bottom(width: int, stops: tuple[str, ...] = ()) -> Text:
width = max(2, width)
line = Text(no_wrap=True)
line.append("╰", style=f"bold {corner_fade(0, stops)}")
_paint(line, _edge_cells(width, "left", stops)) # only the bottom-left fades
line.append("╯", style=EDGE)
return line
def side_style(row: int, rows: int, side: str = "left", stops: tuple[str, ...] = ()) -> str:
"""Colour of a side cell: only two corners fade, so only one end glows."""
if side == "right":
return corner_fade(row + 1, stops) # fades down from the top-right corner
return corner_fade(rows - row, stops) # fades up from the bottom-left corner
def side_row(
row: int, rows: int, side: str = "left", stops: tuple[str, ...] = ()
) -> Text:
return Text("│", style=side_style(row, rows, side, stops))
def wrap(
lines: list[Text],
width: int,
height: int,
title: str = "",
stops: tuple[str, ...] = (),
) -> RenderableType:
"""Frame ``lines`` (already sized for the content box) in a pane."""
width = max(4, width)
height = max(2, height)
inner_width, inner_height = inner_size(width, height)
framed: list[Text] = [top(width, title, stops)]
for index, line in enumerate(lines[:inner_height]):
if cell_len(line.plain) > inner_width:
# A pane must never spill over its neighbour: trim, whatever the
# caller asked for.
line = line.copy()
line.truncate(inner_width, overflow="ellipsis")
row = Text(no_wrap=True)
row.append_text(side_row(index, inner_height, "left", stops))
row.append_text(line)
pad = inner_width - cell_len(line.plain)
if pad > 0:
row.append(" " * pad)
row.append_text(side_row(index, inner_height, "right", stops))
framed.append(row)
while len(framed) < height - 1:
index = len(framed) - 1
row = Text(no_wrap=True)
row.append_text(side_row(index, inner_height, "left", stops))
row.append(" " * inner_width)
row.append_text(side_row(index, inner_height, "right", stops))
framed.append(row)
framed.append(bottom(width, stops))
return Group(*framed[:height])
# --------------------------------------------------------------------------- #
# frame pieces, for containers that cannot draw their own content
# --------------------------------------------------------------------------- #
class FrameTop(Widget):
can_focus = False
def __init__(self, title: str = "", *, id: str | None = None) -> None:
super().__init__(id=id)
self.title = title
def render(self) -> RenderableType:
return top(max(2, self.size.width), self.title, active_stops(self.app))
class FrameBottom(Widget):
can_focus = False
def render(self) -> RenderableType:
return bottom(max(2, self.size.width), active_stops(self.app))
class FrameSide(Widget):
can_focus = False
def __init__(self, side: str = "left", *, id: str | None = None) -> None:
super().__init__(id=id)
self.side = side
def render(self) -> RenderableType:
rows = max(1, self.size.height)
stops = active_stops(self.app)
return Group(
*[side_row(row, rows, self.side, stops) for row in range(rows)]
)
+70 -33
View File
@@ -1,4 +1,4 @@
"""Header: logo, clock and a live ticker."""
"""Header: the catalogue's hero, the clock and a live ticker."""
from __future__ import annotations
@@ -10,7 +10,7 @@ from rich.text import Text
from textual.widget import Widget
from .common import (
SPECTRUM_STOPS,
active_stops,
clock_time,
date_label,
gradient,
@@ -21,13 +21,25 @@ from .common import (
if TYPE_CHECKING:
from ..app import BeefyPlayerApp
LOGO = (
"▄▀▀▀ ▄▀▀▄ █▄ ▄█ ▄▀▀▄",
"▀▀▀▄ █ █ █ █ █ █▀▀█",
"▄▄▄▀ ▀▄▄▀ █ █ █ █",
)
MIDDLE = ("SOMA FM RADIO", "LISTENER SUPPORTED", "SAN FRANCISCO")
def _hero_line(
markup: str,
index: int,
width: int,
gradient_fill: bool,
stops: tuple[str, ...],
) -> Text:
"""One row of the hero: spectrum-painted glyphs, or the art's own colours."""
if not gradient_fill:
line = Text.from_markup(markup)
line.no_wrap = True
return line
line = Text(no_wrap=True)
for column, char in enumerate(markup):
position = (column / max(1, width - 1) + index * 0.055) % 1.0
line.append(char, style=f"bold {gradient(stops, position)}")
return line
class HeaderBar(Widget):
@@ -35,54 +47,69 @@ class HeaderBar(Widget):
def render(self) -> RenderableType:
app = cast("BeefyPlayerApp", self.app)
provider = getattr(app, "provider", None)
hero = tuple(getattr(provider, "hero", ()) or ())
hero_lines = tuple(getattr(provider, "hero_lines", ()) or ())
gradient_fill = bool(getattr(provider, "hero_gradient", False))
stops = active_stops(app)
width = max(24, self.size.width)
height = max(1, self.size.height)
state = app.playing_state
glyph, colour = state_glyph(state)
logo_width = max(cell_len(row) for row in LOGO)
middle_width = max(cell_len(row) for row in MIDDLE)
# Measure what the hero *shows*: the rows are rich markup, so their
# raw length says nothing about the columns they occupy.
hero_width = max(
(cell_len(Text.from_markup(row).plain) for row in hero), default=0
)
middle_width = max((cell_len(row) for row in hero_lines), default=0)
right = (clock_time(), date_label(), f"vol {app.volume}%")
right_width = max(cell_len(item) for item in right)
show_middle = width >= logo_width + middle_width + 8
show_right = width >= logo_width + right_width + 8
show_middle = bool(hero_lines) and width >= hero_width + middle_width + 8
show_right = width >= hero_width + right_width + 8
if show_middle and show_right:
show_middle = width >= logo_width + 4 + middle_width + 2 + right_width
show_middle = (
width >= hero_width + 4 + middle_width + 2 + right_width
)
# Centre the short text block against however tall the hero is: a
# three-line caption beside a seven-row picture sits in the middle.
text_offset = max(0, (len(hero) - len(hero_lines)) // 2)
lines: list[Text] = []
for index, row in enumerate(LOGO):
line = Text(no_wrap=True)
for column, char in enumerate(row):
position = (
column / max(1, logo_width - 1) + index * 0.055
) % 1.0
line.append(
char, style=f"bold {gradient(SPECTRUM_STOPS, position)}"
)
if show_middle:
for index, row in enumerate(hero):
line = _hero_line(row, index, hero_width, gradient_fill, stops)
# Art rows are right-trimmed, so a row ending in a blank cell is a
# column short; pad it back or the caption and clock drift left.
short = hero_width - cell_len(line.plain)
if short > 0:
line.append(" " * short)
if show_middle and text_offset <= index < text_offset + len(hero_lines):
middle = hero_lines[index - text_offset]
line.append(" " * 4)
middle = MIDDLE[index]
line.append(middle, style="dim")
# Keep the right block on one column even though the middle
# lines differ in length ("SOMA FM RADIO" is shorter than
# "LISTENER SUPPORTED").
# lines differ in length ("TILDERADIO" is shorter than
# "STREAMED BY / FOR USERS OF THE").
line.append(" " * max(0, middle_width - cell_len(middle)))
if show_right:
used = logo_width
if show_right and text_offset <= index < text_offset + len(right):
used = hero_width
if show_middle:
used += 4 + middle_width
# Right-align the block *and* every item inside it: the clock,
# the date and the volume each keep their own length, so pad
# the shortest ones instead of left-aligning the whole block.
pad = max(1, width - used - right_width)
item = right[index]
item = right[index - text_offset]
line.append(" " * pad)
line.append(" " * max(0, right_width - cell_len(item)))
style = "bold" if index == 0 else "dim"
style = "bold" if index == text_offset else "dim"
line.append(item, style=style)
lines.append(line)
ticker: Text | None = None
if height >= 4:
ticker = Text(no_wrap=True)
prefix = f"{glyph} {state.upper():<8} "
@@ -94,14 +121,24 @@ class HeaderBar(Widget):
elif station is not None:
song = station.now_playing
if song is not None:
text = f"{station.title} · {song.display_artist} — {song.display_title}"
text = (
f"{station.title} · "
f"{song.display_artist} — {song.display_title}"
)
else:
text = f"{station.title} · {station.description}"
text_style = ""
else:
text = "Pick a station — press enter to tune in"
text_style = "italic dim"
ticker.append(marquee(text, max(4, width - cell_len(prefix))), style=text_style)
lines.append(ticker)
ticker.append(
marquee(text, max(4, width - cell_len(prefix))), style=text_style
)
# A short terminal loses the bottom of the hero before it loses the
# ticker: the ticker carries the state, the art can be cropped.
if ticker is not None:
if len(lines) >= height:
lines = lines[: height - 1]
lines.append(ticker)
return Group(*lines[:height])
+117 -47
View File
@@ -2,24 +2,31 @@
from __future__ import annotations
import sys
import textwrap
from typing import TYPE_CHECKING, cast
import contextlib
from typing import TYPE_CHECKING, ClassVar, cast
from rich.console import Group
from rich.table import Table
from rich.text import Text
from textual.app import ComposeResult
from textual.containers import VerticalScroll
from textual.screen import ModalScreen
from textual.widgets import Static
from .common import SPECTRUM_STOPS, gradient
from .. import __version__
from ..palettes import PALETTE_ORDER, PALETTES
from ..providers import PROVIDERS, available_providers
from .common import active_stops, gradient
if TYPE_CHECKING:
from ..app import BeefyPlayerApp
#: Where the project lives (shown at the foot of the help screen).
URL = "https://tildeforge.dev/beefcarver/beefy-player"
HELP_ROWS: tuple[tuple[str, str], ...] = (
("↑ / ↓ j / k", "move through the station list"),
("pgup / pgdn", "page through the station list"),
("enter", "tune in to the selected station"),
("space", "play / pause the current station"),
("s", "stop playback"),
@@ -28,51 +35,74 @@ HELP_ROWS: tuple[tuple[str, str], ...] = (
("m", "mute / unmute"),
("f", "favourite — favourites float to the top"),
("/", "filter stations by title, genre or description"),
("esc", "clear the filter / close overlays"),
("esc", "close the search box; esc again clears the filter"),
(
"v",
"cycle visualiser: mirror → bars → wave → waterfall → radial → braille",
),
("r", "refresh station list and song history"),
("c", "cycle the colour palette (listed below)"),
("r", "refresh stations, schedule and song history"),
("p", "switch station catalogue (SomaFM / tilderadio)"),
("g / G", "jump to the top / bottom of the list"),
("? ", "this help"),
("q", "quit"),
)
ABOUT_LINUX = (
(
"The spectrum captures your actual PulseAudio/PipeWire output with "
"parec (SYNC); otherwise it falls back to a muted ffmpeg stream tap "
"(FFT, can drift) or a synthesized display (SIM)."
),
(
"The desktop picks us up over MPRIS as a "
"beefyplayer media player — try `playerctl -l` / `playerctl metadata`."
),
)
ABOUT_MACOS = (
(
"The spectrum captures your Mac's real audio output (SYNC) with a "
"native Core Audio process tap — nothing to install. macOS asks for "
"System Audio Recording permission the first time; without it the "
"analyser falls back to a muted ffmpeg stream tap (FFT, can drift) "
"or a synthesized display (SIM)."
),
(
"mpv publishes what is playing to the macOS Now Playing session, so "
"Control Center and the keyboard media keys control this app."
),
)
class HelpScreen(ModalScreen[None]):
def compose(self) -> ComposeResult:
provider = cast("BeefyPlayerApp", self.app).provider
about = (
f"Stations and song history come from {provider.name} "
f"({provider.station_source} + {provider.history_source}).",
) + (ABOUT_MACOS if sys.platform == "darwin" else ABOUT_LINUX)
app = cast("BeefyPlayerApp", self.app)
provider = app.provider
catalogues = Table.grid(padding=(0, 2), expand=False)
catalogues.add_column(style="bold cyan", no_wrap=True, justify="right")
catalogues.add_column(style="bold", no_wrap=True)
catalogues.add_column(style="dim")
for slug in available_providers():
catalogue = PROVIDERS[slug]
marker = "●" if slug == provider.id else " "
pane = "schedule pane" if catalogue.has_schedule else "station list"
catalogues.add_row(
marker, slug, f"{catalogue.name} — {pane}"
)
# -- palettes, two per row so sixteen still fit ------------------ #
names = list(PALETTE_ORDER)
name_width = max(len(name) for name in names)
def swatch(name: str) -> Text:
stops = PALETTES[name]
block = Text(no_wrap=True)
for step in range(8):
block.append("█", style=gradient(stops, step / 7))
return block
def entry(name: str, first: bool) -> Text:
line = Text(no_wrap=True)
if not first:
line.append(" ")
line.append("● " if name == app.palette else " ", style="bold cyan")
line.append(f"{name:<{name_width}}", style="bold")
line.append(" ")
line.append_text(swatch(name))
return line
palette_rows: list[Text] = []
for index in range(0, len(names), 2):
row = entry(names[index], True)
if index + 1 < len(names):
row.append_text(entry(names[index + 1], False))
palette_rows.append(row)
palette_title = Text()
palette_title.append("PALETTES", style="bold cyan")
palette_title.append(
" c cycles them · remembered between runs", style="dim"
)
catalogue_title = Text()
catalogue_title.append("CATALOGUES", style="bold cyan")
catalogue_title.append(
" switch with p or --provider at start", style="dim"
)
table = Table.grid(padding=(0, 2), expand=False)
table.add_column(style="bold cyan", no_wrap=True, justify="right")
@@ -81,18 +111,58 @@ class HelpScreen(ModalScreen[None]):
table.add_row(key, description)
header = Text()
header.append("◤ ", style=f"bold {gradient(SPECTRUM_STOPS, 0.9)}")
header.append("BEEFY PLAYER", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}")
header.append(" keyboard & credits", style="dim")
header.append("◤ ", style=f"bold {gradient(active_stops(self.app), 0.9)}")
header.append("BEEFY PLAYER", style=f"bold {gradient(active_stops(self.app), 0.55)}")
header.append(" keyboard shortcuts", style="dim")
header.append(" · ↑ ↓ / pgup pgdn scroll", style="dim")
footer = Text()
for paragraph in about:
for line in textwrap.wrap(paragraph, width=70):
footer.append(line + "\n", style="dim")
# One attribution line: version and where the project lives.
footer = Text(no_wrap=True)
footer.append(f"beefy player {__version__}", style="bold")
footer.append(" · ")
footer.append(
URL, style=f"dim link {URL}"
)
body = Group(header, Text(""), table, Text(""), footer)
yield Static(body, id="help")
body = Group(
header,
Text(""),
table,
Text(""),
catalogue_title,
catalogues,
Text(""),
palette_title,
*palette_rows,
Text(""),
footer,
)
# The body outgrows a small terminal; the scroll container keeps it
# readable (and gives the box its border).
with VerticalScroll(id="help"):
yield Static(body, id="help-body")
#: Keys that scroll the page instead of closing it (help outgrows a 24-row
#: terminal, so it has to be readable there).
SCROLL_KEYS: ClassVar[dict[str, str]] = {
"up": "scroll_up",
"k": "scroll_up",
"down": "scroll_down",
"j": "scroll_down",
"pageup": "scroll_page_up",
"pagedown": "scroll_page_down",
"home": "scroll_home",
"g": "scroll_home",
"end": "scroll_end",
"shift+g": "scroll_end",
}
def on_key(self, event) -> None:
event.stop()
method = self.SCROLL_KEYS.get(event.key)
if method is not None:
body = self.query_one("#help")
with contextlib.suppress(Exception):
getattr(body, method)(animate=False)
return
self.dismiss(None)
+118
View File
@@ -0,0 +1,118 @@
"""Catalogue picker: switch between SomaFM, tilderadio and whatever comes next."""
from __future__ import annotations
from rich.console import Group
from rich.text import Text
from textual.app import ComposeResult
from textual.containers import VerticalScroll
from textual.screen import ModalScreen
from textual.widgets import Static
from ..providers import PROVIDERS, available_providers
from .common import active_stops, gradient, truncate
class ProviderScreen(ModalScreen[str | None]):
"""A list of catalogues; enter selects, esc leaves everything as it was."""
def __init__(self, current_id: str = "") -> None:
super().__init__()
self.slugs: tuple[str, ...] = available_providers()
self.current_id = current_id
self.index = self.slugs.index(current_id) if current_id in self.slugs else 0
def compose(self) -> ComposeResult:
# A scroll container keeps thirteen catalogues navigable on a short
# terminal; the body is given its exact line count so it measures.
with VerticalScroll(id="providers"):
yield Static(self._body(), id="providers-body")
def on_mount(self) -> None:
body = self.query_one("#providers-body", Static)
body.styles.height = len(self.slugs) + 5 # heading, blank, rows, footer
def _body(self) -> Group:
header = Text()
header.append("◤ ", style=f"bold {gradient(active_stops(self.app), 0.9)}")
header.append("CATALOGUES", style=f"bold {gradient(active_stops(self.app), 0.55)}")
header.append(" · pick a station catalogue", style="dim")
name_width = (
max(
(
len(PROVIDERS[slug].name)
for slug in self.slugs
if slug in PROVIDERS
),
default=12,
)
+ 2
)
rows: list[Text] = []
for index, slug in enumerate(self.slugs):
provider = PROVIDERS[slug]
selected = index == self.index
row = Text(no_wrap=True)
row.append(
"▶ " if selected else " ", style="bold magenta" if selected else ""
)
row.append(f"{index + 1:>2} ", style="dim")
row.append(
f"{truncate(provider.name, name_width - 2):<{name_width}}",
style="bold underline" if selected else "bold",
)
row.append(" ")
# marker(2) + number(5) + name + gap(2) + tagline + current dot(3)
room = max(8, 58 - 2 - 5 - name_width - 2 - 3)
row.append(truncate(provider.tagline, room), style="italic dim")
if slug == self.current_id:
row.append(" ●", style="bold green")
rows.append(row)
current = PROVIDERS.get(self.current_id)
footer = Text()
footer.append("\n")
if current is not None:
line = truncate(f"now: {current.name} — {current.station_source}", 58)
footer.append(line + "\n", style="dim")
footer.append(
"↑ ↓ choose · enter switch · esc cancel",
style="dim",
)
return Group(header, Text(""), *rows, footer)
# -- interaction ----------------------------------------------------- #
def _move(self, delta: int) -> None:
self.index = (self.index + delta) % len(self.slugs)
body = self.query_one("#providers-body", Static)
body.update(self._body())
box = self.query_one("#providers", VerticalScroll)
def follow() -> None:
visible = max(1, box.size.height)
target = max(0, 2 + self.index - (visible - 3) // 2)
box.scroll_to(y=target, animate=False)
box.call_after_refresh(follow)
def on_key(self, event) -> None:
key = event.key
if key in ("up", "k", "shift+tab"):
self._move(-1)
elif key in ("down", "j", "tab"):
self._move(1)
elif key in ("enter", "space"):
self.dismiss(self.slugs[self.index])
elif key == "escape" or key == "q":
self.dismiss(None)
elif key.isdigit() and key != "0":
choice = int(key) - 1
if 0 <= choice < len(self.slugs):
self.dismiss(self.slugs[choice])
else:
return
event.stop()
+146
View File
@@ -0,0 +1,146 @@
"""The left pane for single-stream catalogues: what is on, what is next.
A catalogue with one station does not need a station list, but it usually has
a broadcast schedule. This pane answers the two questions a listener actually
has — "who is on air?" and "when is the next show?" — in the space the list
would have used.
"""
from __future__ import annotations
from datetime import date, datetime, timedelta
from typing import TYPE_CHECKING, cast
from rich.console import RenderableType
from rich.text import Text
from textual.widget import Widget
from . import frame
from .common import active_stops, local_time, local_zone, state_glyph, truncate
if TYPE_CHECKING:
from ..app import BeefyPlayerApp
from ..models import Broadcast
def _window(broadcast: Broadcast | None) -> str:
"""``since 22:00 · until 23:00`` — whichever ends the feed actually sent."""
if broadcast is None:
return ""
start = local_time(broadcast.start) if broadcast.start else ""
end = local_time(broadcast.end) if broadcast.end else ""
if start and end:
return f"since {start} · until {end}"
if start:
return f"since {start}"
if end:
return f"until {end}"
return ""
def _day_label(day: date, today: date) -> str:
if day == today:
return "TODAY"
if day == today + timedelta(days=1):
return "TOMORROW"
return day.strftime("%a %d %b").upper()
def _upcoming_rows(
upcoming: list[Broadcast], width: int, budget: int
) -> list[Text]:
"""Upcoming shows, grouped by local day, clipped to the pane."""
zone = local_zone()
today = datetime.now(zone).date()
# (is a show, its line) so the "… +N more" count talks about shows and
# not about the day headings in between.
rows: list[tuple[bool, Text]] = []
current_day: date | None = None
for item in upcoming:
if item.start is None:
continue
day = item.start.astimezone(zone).date()
if day != current_day:
current_day = day
rows.append((False, Text(_day_label(day, today), style="dim")))
row = Text(no_wrap=True)
row.append(" ")
row.append(local_time(item.start), style="bold")
row.append(" ")
row.append(truncate(item.display_name, max(4, width - 9)))
rows.append((True, row))
if len(rows) > budget:
kept = rows[: max(0, budget - 1)]
while kept and not kept[-1][0]:
kept.pop()
dropped = sum(1 for is_show, _ in rows[len(kept) :] if is_show)
lines = [text for _, text in kept]
if dropped:
lines.append(Text(f"… +{dropped} more", style="dim italic"))
return lines
return [text for _, text in rows]
class SchedulePanel(Widget):
can_focus = False
def __init__(self) -> None:
super().__init__()
self.border_title = "SCHEDULE"
def render(self) -> RenderableType:
app = cast("BeefyPlayerApp", self.app)
width = max(12, self.size.width)
height = max(3, self.size.height)
stops = active_stops(app)
width, height = frame.inner_size(width, height)
broadcasts = list(getattr(app, "schedule", ()) or ())
loaded = bool(getattr(app, "schedule_loaded", False))
live = next((item for item in broadcasts if item.live), None)
upcoming = [item for item in broadcasts if not item.live]
lines: list[Text] = []
head = Text(no_wrap=True)
glyph, colour = state_glyph("playing" if live else "stopped")
head.append(f"{glyph} ", style=f"bold {colour}")
head.append("ON AIR" if live else "AUTO DJ", style="bold magenta")
if live is not None:
head.append(" ", style="")
head.append(
truncate(live.display_name, max(4, width - 12)),
style="bold",
)
lines.append(head)
window = _window(live)
lines.append(
Text(truncate(window, width), style="dim")
if window
else Text("library rotation", style="dim italic")
)
lines.append(Text(""))
lines.append(Text("UPCOMING", style="bold cyan"))
if not loaded:
lines.append(Text("loading schedule…", style="dim italic"))
elif not upcoming:
lines.append(Text("nothing scheduled", style="dim italic"))
else:
lines.extend(
_upcoming_rows(upcoming, width, max(0, height - 1 - len(lines)))
)
lines.append(Text(""))
while len(lines) < height:
lines.append(Text(""))
return frame.wrap(
lines[:height],
max(12, self.size.width),
max(3, self.size.height),
self.border_title,
stops,
)
+28 -20
View File
@@ -7,12 +7,14 @@ from collections import deque
from collections.abc import Sequence
from typing import TYPE_CHECKING, cast
from rich.console import Group, RenderableType
from rich.console import RenderableType
from rich.text import Text
from textual.widget import Widget
from ..palettes import palette_stops
from ..spectrum import Frame, SpectrumEngine
from .common import LEVELS, SPECTRUM_STOPS, gradient
from . import frame
from .common import LEVELS, active_stops, gradient
if TYPE_CHECKING:
from ..app import BeefyPlayerApp
@@ -47,7 +49,9 @@ class SpectrumPanel(Widget):
super().__init__()
self.engine = engine
self.border_title = "SPECTRUM"
self._palettes: dict[tuple[int, int, bool], list[str]] = {}
#: The active colour ladder; ``render()`` refreshes it each frame.
self._stops: tuple[str, ...] = palette_stops(None)
self._palettes: dict[tuple, list[str]] = {}
#: Recent band frames, newest last, for the waterfall display. Filled
#: even while another mode is showing so switching to it has history.
self._history: deque[tuple[float, ...]] = deque(maxlen=WATERFALL_HISTORY)
@@ -63,38 +67,41 @@ class SpectrumPanel(Widget):
def render(self) -> RenderableType:
app = cast("BeefyPlayerApp", self.app)
frame = self.engine.snapshot()
width = max(8, self.size.width)
height = max(2, self.size.height)
snapshot = self.engine.snapshot()
self._stops = active_stops(app)
outer_width, outer_height = max(12, self.size.width), max(4, self.size.height)
width, height = frame.inner_size(outer_width, outer_height)
mode = getattr(app, "visualizer", "both")
phase = int(getattr(app, "tick_count", 0) // 2)
dim = frame.status == "idle"
self._record(frame, int(getattr(app, "tick_count", 0)))
dim = snapshot.status == "idle"
self._record(snapshot, int(getattr(app, "tick_count", 0)))
lines: list[Text]
if mode == "wave":
lines = self._wave(frame, width, height, phase, dim)
lines = self._wave(snapshot, width, height, phase, dim)
elif mode == "braille":
lines = self._braille(frame, width, height, phase, dim)
lines = self._braille(snapshot, width, height, phase, dim)
elif mode == "waterfall":
lines = self._waterfall(width, height, dim)
elif mode == "radial":
lines = self._radial(frame, width, height, phase, dim)
lines = self._radial(snapshot, width, height, phase, dim)
elif mode == "bars" or height < 8:
lines = self._bars(frame, width, height, phase, dim)
lines = self._bars(snapshot, width, height, phase, dim)
else:
# Default mode: a full-height mirrored spectrum. The top half
# grows up from the centre line, the bottom half grows down from
# it (same values, flipped rows, dimmed like a reflection).
top_height = height // 2
bottom_height = height - top_height
lines = self._bars(frame, width, top_height, phase, dim)
lines = self._bars(snapshot, width, top_height, phase, dim)
mirrored = self._bars(
frame, width, bottom_height, phase + 5, True, peaks=False
snapshot, width, bottom_height, phase + 5, True, peaks=False
)
mirrored.reverse()
lines.extend(mirrored)
return Group(*lines)
return frame.wrap(
lines, outer_width, outer_height, self.border_title, self._stops
)
# ------------------------------------------------------------------ #
# history
@@ -124,14 +131,15 @@ class SpectrumPanel(Widget):
return float(values[low]) * (1.0 - fraction) + float(values[high]) * fraction
def _row_palette(self, height: int, phase: int, dim: bool) -> list[str]:
key = (height, phase % 12, dim)
stops = self._stops
key = (height, phase % 12, dim, stops)
cached = self._palettes.get(key)
if cached is not None:
return cached
palette: list[str] = []
for row in range(height):
position = (height - 1 - row) / max(1, height - 1)
colour = gradient(SPECTRUM_STOPS, position)
colour = gradient(stops, position)
style = f"bold {colour}" if (row + phase) % 7 == 0 else colour
if dim:
style = f"dim {style}"
@@ -328,7 +336,7 @@ class SpectrumPanel(Widget):
level = int(value * (len(WATERFALL_LEVELS) - 1))
if level <= 0:
continue
style = gradient(SPECTRUM_STOPS, value)
style = gradient(self._stops, value)
if faded:
style = f"dim {style}"
elif age == 1:
@@ -389,11 +397,11 @@ class SpectrumPanel(Widget):
if depth < 0.06:
grid[row][column] = "█"
styles[row][column] = ("dim " if dim else "") + gradient(
SPECTRUM_STOPS, edge
self._stops, edge
)
elif depth < 0.15:
grid[row][column] = "▓"
styles[row][column] = "dim " + gradient(SPECTRUM_STOPS, edge)
styles[row][column] = "dim " + gradient(self._stops, edge)
elif depth < 0.25:
grid[row][column] = "▒"
styles[row][column] = "dim"
+23 -9
View File
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, cast
from rich.console import Group, RenderableType
from rich.text import Text
from textual.containers import Horizontal
from textual.containers import Horizontal, Vertical
from textual.css.query import NoMatches
from textual.widget import Widget
@@ -21,6 +21,7 @@ from .common import (
time_ago,
truncate,
)
from .frame import FrameBottom, FrameSide, FrameTop
if TYPE_CHECKING:
from ..app import BeefyPlayerApp
@@ -36,10 +37,18 @@ class StagePanel(Widget):
self.border_title = "ON AIR"
def compose(self):
with Horizontal(id="stage_top"):
yield ArtPane()
yield StageInfo()
yield HistoryPane()
# A container cannot paint its own content, so the frame is made of
# thin widgets: top, bottom, and a column either side of the body.
yield FrameTop("ON AIR", id="stage_frame_top")
with Horizontal(id="stage_middle"):
yield FrameSide("left", id="stage_frame_left")
with Vertical(id="stage_body"):
with Horizontal(id="stage_top"):
yield ArtPane()
yield StageInfo()
yield HistoryPane()
yield FrameSide("right", id="stage_frame_right")
yield FrameBottom(id="stage_frame_bottom")
def on_mount(self) -> None:
self.refresh_art()
@@ -140,14 +149,19 @@ class StageInfo(Widget):
album = Text(no_wrap=True)
if song.album:
album.append(truncate(song.album, max(8, width - 22)), style="dim")
if station.dj:
presenter = station.presenter_label
if presenter:
if song.album:
album.append(" · ", style="dim")
album.append(f"dj {truncate(station.dj, 18)}", style="dim")
if song.art_url:
album.append(truncate(presenter, max(8, width - 22)), style="dim")
# The marker means "there is artwork", not "here is a lone box":
# without album or presenter there is nothing for it to mark.
if song.art_url and album.plain.strip():
album.append(" ▣", style="cyan")
else:
artist = Text("waiting for track data…", style="italic yellow")
# No track metadata from this catalogue: keep the line, leave it
# blank rather than filling it with an apology.
artist = Text("")
fallback = station.last_playing or "tuning in"
title = Text(marquee(fallback, width), style="bold")
album = Text(truncate(station.genre_label, width), style="dim")
+89 -20
View File
@@ -1,19 +1,23 @@
"""Station list with favourites, live markers and a filter."""
"""Station list with favourites, live markers, a filter and a scrollbar."""
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from rich.cells import cell_len
from rich.console import Group, RenderableType
from rich.console import RenderableType
from rich.text import Text
from textual.widget import Widget
from .common import compact_count, truncate
from . import frame
from .common import active_stops, compact_count, truncate
if TYPE_CHECKING:
from ..app import BeefyPlayerApp
#: Columns the scrollbar occupies (a gap plus the bar itself).
GUTTER = 2
class StationList(Widget):
can_focus = False
@@ -22,34 +26,94 @@ class StationList(Widget):
super().__init__()
self.border_title = "STATIONS"
# -- title: filter + position --------------------------------------- #
def set_filter(self, query: str) -> None:
self._sync_title(query)
def _sync_title(self, query: str | None = None) -> None:
"""``STATIONS · /jazz · 3/12`` — the filter and where you are in it."""
if query is None:
query = ""
total = 0
index = 0
if self.is_mounted:
app = cast("BeefyPlayerApp", self.app)
query = app.query
total = len(app.view)
index = app.index
safe_query = query.replace("[", "(").replace("]", ")") if query else ""
title = f"STATIONS · /{safe_query}" if safe_query else "STATIONS"
parts = ["STATIONS"]
if safe_query:
parts.append(f"/{safe_query}")
if total > 1:
parts.append(f"{min(index + 1, total)}/{total}")
title = " · ".join(parts)
if self.border_title != title:
self.border_title = title
def refresh(self, *args, **kwargs): # type: ignore[override]
# Every cursor move ends in a refresh, so this is the one place that
# has to keep the title's position readout honest.
if self.is_mounted:
self._sync_title()
return super().refresh(*args, **kwargs)
def on_mouse_scroll_down(self, event) -> None:
cast("BeefyPlayerApp", self.app).action_cursor_down()
def on_mouse_scroll_up(self, event) -> None:
cast("BeefyPlayerApp", self.app).action_cursor_up()
# -- scrollbar geometry --------------------------------------------- #
@staticmethod
def thumb(height: int, total: int, first: int) -> set[int]:
"""Rows of the pane the scrollbar thumb covers."""
if total <= height:
return set(range(height))
size = max(1, round(height * height / total))
span = max(0, total - height)
top = 0 if span == 0 else round(first * (height - size) / span)
return set(range(top, min(height, top + size)))
def render(self) -> RenderableType:
app = cast("BeefyPlayerApp", self.app)
width = max(12, self.size.width)
height = max(1, self.size.height)
height = max(3, self.size.height)
stops = active_stops(app)
inner_width, inner_height = frame.inner_size(width, height)
view = app.view
if not view:
message = "no stations match" if app.query else "loading stations…"
return Group(Text(truncate(message, width), style="dim italic"))
if getattr(app, "search_pending", False):
message = "searching the directory…"
else:
message = "no stations match" if app.query else "loading stations…"
return frame.wrap(
[Text(truncate(message, inner_width), style="dim italic")],
width,
height,
self.border_title,
stops,
)
total = len(view)
first = 0
if len(view) > height:
first = max(0, min(app.index - height // 2, len(view) - height))
if total > inner_height:
first = max(0, min(app.index - inner_height // 2, total - inner_height))
scrollable = total > inner_height
gutter = GUTTER if scrollable else 0
content = max(4, inner_width - gutter)
thumb = (
self.thumb(inner_height, total, first) if scrollable else set()
)
lines: list[Text] = []
for offset in range(min(height, len(view))):
for offset in range(min(inner_height, total)):
index = first + offset
station = view[index]
selected = index == app.index
@@ -64,32 +128,29 @@ class StationList(Widget):
count_text = ""
count_width = 0
if width >= 26 and station.listeners > 0:
if content >= 26 and station.listeners > 0:
count_text = f"{compact_count(station.listeners)} ♪"
count_width = cell_len(count_text) + 1
prefix = marker + star + play + " "
available = width - cell_len(prefix) - count_width
available = content - cell_len(prefix) - count_width
title = truncate(station.title, max(4, available))
gap = max(0, width - cell_len(prefix) - cell_len(title) - count_width)
gap = max(0, content - cell_len(prefix) - cell_len(title) - count_width)
if selected:
base = ""
marker_style = "magenta"
title_style = "bold underline"
count_style = "bold"
elif current:
base = ""
marker_style = "cyan"
title_style = "cyan"
count_style = "dim"
else:
base = ""
marker_style = ""
title_style = ""
count_style = "dim"
row = Text(no_wrap=True, style=base)
row = Text(no_wrap=True)
row.append(marker, style=marker_style)
row.append(star, style="bold yellow" if station.favorite else "")
row.append(play, style="cyan" if playing else "")
@@ -99,8 +160,16 @@ class StationList(Widget):
if count_text:
row.append(count_text, style=count_style)
used = cell_len(prefix) + cell_len(title) + gap + cell_len(count_text)
if used < width:
row.append(" " * (width - used))
if used < content:
row.append(" " * (content - used))
if scrollable:
# Just the thumb, in the frame's own grey: no rail.
row.append(" ")
row.append(
"▐" if offset in thumb else " ",
style=frame.EDGE,
)
lines.append(row)
return Group(*lines)
return frame.wrap(lines, width, height, self.border_title, stops)
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 "python@3.14"
# 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.
+3 -3
View File
@@ -1,13 +1,13 @@
[project]
name = "beefy-player"
version = "0.1.0"
description = "A flashy terminal player for internet radio — live spectrum analyser, MPRIS / macOS Now Playing support, song history and pluggable station catalogues (SomaFM included)"
version = "1.2.2"
description = "A flashy terminal player for internet radio — live spectrum analyser, MPRIS / macOS Now Playing support, song history and pluggable station catalogues (SomaFM, Radio Paradise, dublab, Rinse FM and more)"
readme = "README.md"
license = "WTFPL"
license-files = ["LICENSE"]
requires-python = ">=3.10"
authors = [{ name = "beefy-player contributors" }]
keywords = ["radio", "internet-radio", "tui", "terminal", "mpris", "audio", "somafm"]
keywords = ["radio", "internet-radio", "tui", "terminal", "mpris", "audio", "somafm", "tilderadio", "bassdrive", "radioparadise", "radiobrowser", "dublab"]
classifiers = [
"Environment :: Console",
"Intended Audience :: End Users/Desktop",
+3
View File
@@ -82,6 +82,9 @@ Version=2
assert base.is_playlist("https://x/y.pls?foo=1") is True
assert base.is_direct_stream("https://ice1.somafm.com/x-128-mp3") is True
assert base.is_direct_stream("https://x/y.m3u") is False
# HLS is the player's job, not ours: mpv reads the playlist itself
assert base.is_playlist("https://x/live/index.m3u8") is False
assert base.is_direct_stream("https://x/live/index.m3u8") is True
def test_parse_songs_and_dates() -> None:
+365 -3
View File
@@ -8,7 +8,9 @@ import pytest
from beefy_player import config
from beefy_player.app import BeefyPlayerApp
from beefy_player.models import Song
from beefy_player.models import Song, Station
from beefy_player.providers.base import StationPage
from beefy_player.widgets.art import ArtPane
from beefy_player.widgets.help import HelpScreen
from beefy_player.widgets.stage import StagePanel
from beefy_player.widgets.station_list import StationList
@@ -69,8 +71,10 @@ async def test_boot_navigation_filter_favourite_visualizer_help() -> None:
await pilot.pause()
assert app.current is not None
assert app.current.id == "defcon"
assert app.query == ""
assert len(app.view) == len(app.stations)
assert app.query == "defcon"
assert [station.id for station in app.view] == ["defcon"]
assert not search.has_class("visible")
assert app.focused is None
assert app.playing_state in ("stopped", "error")
app.action_favorite()
@@ -99,6 +103,314 @@ async def test_boot_navigation_filter_favourite_visualizer_help() -> None:
assert "DEF CON" in text.upper()
@pytest.mark.asyncio
async def test_escape_leaves_the_filter_sticky_and_a_second_escape_clears_it() -> None:
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
await pilot.press("slash")
for char in "defcon":
await pilot.press(char)
await pilot.pause()
search = app.query_one("#search")
assert search.has_class("visible")
assert app.query == "defcon"
assert [station.id for station in app.view] == ["defcon"]
await pilot.press("escape")
await pilot.pause()
assert app.query == "defcon"
assert search.value == "defcon"
assert not search.has_class("visible")
assert app.focused is None
assert [station.id for station in app.view] == ["defcon"]
assert "defcon" in app.query_one(StationList).border_title
app.action_favorite()
await pilot.pause()
assert "defcon" in app.config.favorites
assert app.query == "defcon"
assert [station.id for station in app.view] == ["defcon"]
await pilot.press("escape")
await pilot.pause()
assert app.query == ""
assert search.value == ""
assert len(app.view) == len(app.stations)
@pytest.mark.asyncio
async def test_enter_keeps_the_filter_sticky_like_the_first_escape() -> None:
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
await pilot.press("slash")
for char in "defcon":
await pilot.press(char)
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
search = app.query_one("#search")
assert app.current is not None
assert app.current.id == "defcon"
assert app.query == "defcon"
assert [station.id for station in app.view] == ["defcon"]
assert not search.has_class("visible")
await pilot.press("escape")
await pilot.pause()
assert app.query == ""
assert len(app.view) == len(app.stations)
@pytest.mark.asyncio
async def test_directory_search_uses_the_server_not_the_loaded_list(monkeypatch) -> None:
app = BeefyPlayerApp(
provider="radiobrowser", autoplay=False, offline=True, audio=False
)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
app.offline = False
found = Station(
id="deep-jazz",
title="Deep Jazz",
description="France · french",
genre="jazz",
provider="radiobrowser",
)
queries: list[str] = []
async def fake_search(self, client, query):
queries.append(query)
return [found]
monkeypatch.setattr(type(app.provider), "search_stations", fake_search)
app.query = "jazz"
app._rebuild_view()
assert not any(station.id == "deep-jazz" for station in app.view)
worker = app._search_directory("jazz")
await worker.wait()
await pilot.pause()
assert queries == ["jazz"]
assert [station.id for station in app.view] == ["deep-jazz"]
assert app._find_station("deep-jazz") is found
app._tune(found)
assert any(station.id == "deep-jazz" for station in app.stations)
app._set_stations(
[Station(id="other", title="Other", provider="radiobrowser")]
)
assert app.current.id == "deep-jazz"
assert any(station.id == "deep-jazz" for station in app.stations)
app.query = ""
app.search_results = None
app._search_results_query = ""
app._rebuild_view()
assert len(app.view) == len(app.stations)
@pytest.mark.asyncio
async def test_a_refresh_replaces_a_fallback_station() -> None:
app = BeefyPlayerApp(
provider="radiobrowser", autoplay=False, offline=True, audio=False
)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
assert app.current is not None
assert app.current.id.startswith("fallback-")
app._set_stations(
[Station(id="real", title="Real Station", provider="radiobrowser")]
)
assert app.current.id == "real"
assert [station.id for station in app.stations] == ["real"]
@pytest.mark.asyncio
async def test_favoriting_a_loaded_station_from_search_updates_it_in_place() -> None:
app = BeefyPlayerApp(
provider="radiobrowser", autoplay=False, offline=True, audio=False
)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
existing = app.stations[0]
search_copy = Station(
id=existing.id, title=existing.title, provider="radiobrowser"
)
app.query = "jazz"
app._set_search_results("jazz", [search_copy])
assert app.view[0] is existing
app.index = 0
app.action_favorite()
await pilot.pause()
assert existing.favorite is True
assert app.config.is_favorite(existing.id)
app._close_search(clear=True)
await pilot.pause()
assert app.view[0].id == existing.id
@pytest.mark.asyncio
async def test_favoriting_a_directory_result_keeps_it_after_clearing_the_filter(
monkeypatch,
) -> None:
app = BeefyPlayerApp(
provider="radiobrowser", autoplay=False, offline=True, audio=False
)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
app.offline = False
found = Station(
id="deep-jazz", title="Deep Jazz", provider="radiobrowser"
)
async def fake_search(self, client, query):
return [found]
monkeypatch.setattr(type(app.provider), "search_stations", fake_search)
await pilot.press("slash")
for char in "jazz":
await pilot.press(char)
await pilot.pause(0.4)
assert [station.id for station in app.view] == ["deep-jazz"]
await pilot.press("escape")
await pilot.pause()
app.action_favorite()
await pilot.pause()
assert found.favorite is True
assert "deep-jazz" in app.config.favorites
await pilot.press("escape")
await pilot.pause()
assert app.query == ""
assert app.view[0].id == "deep-jazz"
@pytest.mark.asyncio
async def test_missing_favorites_load_with_the_first_directory_page(
monkeypatch,
) -> None:
app = BeefyPlayerApp(
provider="radiobrowser", autoplay=False, offline=True, audio=False
)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
app.offline = False
favourite = Station(
id="12345678-1234-1234-1234-123456789abc",
title="Favourite",
provider="radiobrowser",
)
app.config.favorites = [favourite.id]
page = StationPage(
stations=[
Station(id="page-0", title="Page Zero", provider="radiobrowser")
],
next_offset=100,
)
async def fake_page(self, client, offset=0):
return page
async def fake_by_ids(self, client, station_ids):
assert station_ids == [favourite.id]
return [favourite]
monkeypatch.setattr(type(app.provider), "fetch_station_page", fake_page)
monkeypatch.setattr(
type(app.provider), "fetch_stations_by_ids", fake_by_ids
)
worker = app._refresh_stations()
await worker.wait()
await pilot.pause()
assert any(
station.id == favourite.id and station.favorite
for station in app.stations
)
assert app.view[0].id == favourite.id
@pytest.mark.asyncio
async def test_a_paginated_catalogue_loads_the_next_page_near_the_end(
monkeypatch,
) -> None:
app = BeefyPlayerApp(
provider="radiobrowser", autoplay=False, offline=True, audio=False
)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
app.offline = False
first = [
Station(
id=f"s{index}", title=f"Station {index}", provider="radiobrowser"
)
for index in range(100)
]
app._set_stations(first)
app._reset_directory_paging(first, has_more=True)
assert app._directory_offset == 100
assert app._directory_exhausted is False
app.index = 0
app._maybe_load_more_stations()
assert app._loading_more is False
seen: list[int] = []
async def fake_page(self, client, offset=0):
seen.append(offset)
return StationPage(
stations=[
Station(id="extra", title="Extra", provider="radiobrowser")
],
next_offset=None,
)
monkeypatch.setattr(type(app.provider), "fetch_station_page", fake_page)
app.index = 99
worker = app._load_more_stations()
await worker.wait()
await pilot.pause()
assert seen == [100]
assert any(station.id == "extra" for station in app.stations)
assert app._directory_exhausted is True
assert app.view[app.index].id == "s99"
@pytest.mark.asyncio
async def test_a_popularity_ordered_catalogue_keeps_its_order() -> None:
app = BeefyPlayerApp(
provider="radiobrowser", autoplay=False, offline=True, audio=False
)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
app._set_stations(
[
Station(id="zulu", title="Zulu", provider="radiobrowser"),
Station(id="alpha", title="Alpha", provider="radiobrowser"),
]
)
assert [station.title for station in app.view] == ["Zulu", "Alpha"]
@pytest.mark.asyncio
async def test_stage_renders_history_and_listener_count() -> None:
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
@@ -259,3 +571,53 @@ async def test_media_session_commands_drive_the_app() -> None:
app.media_actions.put("volume:25")
await pilot.pause(0.2)
assert app.volume == 25
@pytest.mark.asyncio
async def test_menus_take_the_inline_artwork_down_and_put_it_back(monkeypatch) -> None:
"""Terminal images sit over a modal unless the image is deleted first."""
calls: list[str] = []
monkeypatch.setattr(ArtPane, "suspend", lambda self: calls.append("suspend"))
monkeypatch.setattr(ArtPane, "resume", lambda self: calls.append("resume"))
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(100, 30)) as pilot:
await pilot.pause()
await pilot.press("question_mark")
await pilot.pause()
assert calls == ["suspend"]
await pilot.press("x")
await pilot.pause()
assert calls == ["suspend", "resume"]
await pilot.press("p")
await pilot.pause()
assert calls[-1] == "suspend"
await pilot.press("escape")
await pilot.pause()
assert calls[-1] == "resume"
@pytest.mark.asyncio
async def test_the_mosaic_gives_way_to_a_real_image(monkeypatch) -> None:
"""The pattern must not peek out around a letterboxed picture."""
from beefy_player.widgets import art as art_module
monkeypatch.setattr(art_module.artwork, "supported", lambda: True)
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(110, 34)) as pilot:
await pilot.pause()
pane = app.query_one(ArtPane)
assert pane.display, "the artwork pane should be on screen at this size"
app.artwork_bytes = None
patterned = [line.plain for line in pane.render().renderables]
assert patterned and any(line.strip() for line in patterned)
app.artwork_bytes = b"pretend this is a png"
blank = [line.plain for line in pane.render().renderables]
assert len(blank) == len(patterned)
assert len(blank[0]) == len(patterned[0])
assert all(not line.strip() for line in blank)
+63 -6
View File
@@ -9,9 +9,20 @@ from PIL import Image
from beefy_player import artwork
def png_bytes(size=(400, 200)) -> bytes:
def png_bytes(size=(400, 200), alpha: int = 180) -> bytes:
buffer = BytesIO()
Image.new("RGBA", size, (20, 180, 220, 180)).save(buffer, format="PNG")
Image.new("RGBA", size, (20, 180, 220, alpha)).save(buffer, format="PNG")
return buffer.getvalue()
def ink_bbox(image: Image.Image):
"""Where the artwork itself is, ignoring transparent or black padding."""
return image.convert("RGB").getbbox()
def opaque_png_bytes(size=(400, 200)) -> bytes:
buffer = BytesIO()
Image.new("RGB", size, (20, 180, 220)).save(buffer, format="PNG")
return buffer.getvalue()
@@ -35,12 +46,58 @@ def test_protocol_detection() -> None:
assert artwork.detect_protocol({"SOMAFM_IMAGES": "0"}) is None
def test_fit_image_crops_to_the_cell_box() -> None:
fitted = artwork.fit_image(png_bytes(), 13, 8)
def test_fit_image_letterboxes_inside_the_cell_box() -> None:
fitted = artwork.fit_image(png_bytes((400, 200)), 13, 8)
assert fitted is not None
cell_width, cell_height = artwork.cell_size()
expected = (13 * cell_width) / (8 * cell_height)
assert fitted.width / fitted.height == pytest.approx(expected, rel=0.01)
assert (fitted.width, fitted.height) == (13 * cell_width, 8 * cell_height)
# the ink keeps its own 2:1 aspect instead of being cropped to the box
ink = ink_bbox(fitted)
assert ink is not None
left, top, right, bottom = ink
assert (right - left) / (bottom - top) == pytest.approx(2.0, rel=0.02)
# a wide image is padded above and below, and spans the full width
assert left == 0 and right == fitted.width
assert top > 0 and bottom < fitted.height
def test_an_image_with_alpha_gets_a_black_backing() -> None:
"""Transparent artwork is drawn over the mosaic; black keeps it readable."""
fitted = artwork.fit_image(png_bytes((400, 200)), 13, 8)
assert fitted is not None
# every pixel is opaque now, and the padding is black rather than mosaic
assert fitted.getchannel("A").getextrema() == (255, 255)
assert fitted.getpixel((0, 0)) == (0, 0, 0, 255)
# the ink is still letterboxed, not cropped: its own 2:1 aspect survives
ink = ink_bbox(fitted)
assert ink is not None
left, top, right, bottom = ink
assert (right - left) / (bottom - top) == pytest.approx(2.0, rel=0.02)
def test_an_opaque_image_keeps_the_transparent_padding() -> None:
"""No alpha channel: nothing to hide, so the mosaic shows through."""
fitted = artwork.fit_image(opaque_png_bytes((400, 200)), 13, 8)
assert fitted is not None
assert fitted.getpixel((0, 0))[3] == 0
ink = ink_bbox(fitted)
assert ink is not None
left, top, right, bottom = ink
assert (right - left) / (bottom - top) == pytest.approx(2.0, rel=0.02)
def test_fit_image_centres_a_tall_image() -> None:
fitted = artwork.fit_image(png_bytes((100, 400)), 13, 8)
assert fitted is not None
ink = ink_bbox(fitted)
assert ink is not None
left, _, right, _ = ink
assert left > 0 and right < fitted.width
assert right - left == pytest.approx(fitted.height / 4, rel=0.05)
def test_fit_image_rejects_garbage() -> None:
assert artwork.fit_image(b"not an image", 13, 8) is None
+278
View File
@@ -0,0 +1,278 @@
"""Bassdrive: Icecast status, show titles, the homepage schedule, rolling history."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import httpx
import pytest
from beefy_player.models import Broadcast
from beefy_player.providers.bassdrive import BassdriveProvider, api
STATUS = {
"icestats": {
"source": [
{
"listenurl": "https://ice.bassdrive.net:443/stream",
"bitrate": 128,
"server_type": "audio/mpeg",
"listeners": 31,
"display-title": "Ben XO :: LIVE :: Night Shift Over [discord.gg/bassdrive]",
},
{
"listenurl": "https://ice.bassdrive.net:443/stream56",
"bitrate": 56,
"server_type": "audio/aacp",
"listeners": 5,
"display-title": "Ben XO :: LIVE :: Night Shift Over [discord.gg/bassdrive]",
},
{
"listenurl": "https://ice.bassdrive.net:443/stream32",
"bitrate": 32,
"server_type": "audio/aacp",
"listeners": 2,
"display-title": "Ben XO :: LIVE :: Night Shift Over [discord.gg/bassdrive]",
},
]
}
}
# Trimmed from the real homepage: the same markup the site ships.
SCHEDULE_PAGE = """
<ul class="schedule">
<li>
<a href="radio/get-dipped">
<p class="title">Get Dipped <span class="location">Los Angeles, CA</span></p>
<span class="time">
<local-time datetime="2026-11-03T19:00:00-05:00" hour="numeric"></local-time> -
<local-time datetime="2026-11-03T21:00:00-05:00" hour="numeric"></local-time>
</span>
</a>
</li>
<li>
<a href="radio/drumobsession">
<p class="title">DrumObsession <span class="location">Poznan, Poland</span></p>
<span class="time">
<local-time datetime="2026-11-03T11:00:00-05:00" hour="numeric"></local-time> -
<local-time datetime="2026-11-03T13:00:00-05:00" hour="numeric"></local-time>
</span>
</a>
</li>
</ul>
"""
@pytest.fixture(autouse=True)
def fresh_memo():
api.reset_schedule_memo()
yield
api.reset_schedule_memo()
def test_split_title_reads_both_conventions() -> None:
assert api.split_title("Ben XO :: LIVE :: Night Shift Over [promo]") == (
"Ben XO",
"Night Shift Over",
True,
)
# plenty of shows simply append LIVE
assert api.split_title("Sohl Patrol LIVE") == ("", "Sohl Patrol", True)
assert api.split_title("Bassdrive Auto DJ") == ("", "Bassdrive Auto DJ", False)
def test_mounts_become_playlists_best_first_and_listeners_add_up() -> None:
playlists = api.playlists_from_status(STATUS)
assert [entry["bitrate"] for entry in playlists] == ["128", "56", "32"]
assert [entry["quality"] for entry in playlists] == ["highest", "high", "low"]
assert playlists[0]["format"] == "mp3" and playlists[1]["format"] == "aacp"
assert api.status_listeners(STATUS) == 38
def test_parse_schedule_reads_the_homepage_grid() -> None:
entries = api.parse_schedule(SCHEDULE_PAGE)
assert [entry.name for entry in entries] == ["DrumObsession", "Get Dipped"]
assert entries[0].location == "Poznan, Poland"
assert entries[0].start == datetime(2026, 11, 3, 11, tzinfo=timezone(timedelta(hours=-5)))
assert entries[0].end == datetime(2026, 11, 3, 13, tzinfo=timezone(timedelta(hours=-5)))
# the schedule list itself is not a schedule slot
assert api.parse_schedule("<ul><li>nothing here</li></ul>") == []
def test_roll_forward_repeats_the_week() -> None:
start = datetime(2026, 11, 3, 11, tzinfo=timezone(timedelta(hours=-5)))
entry = Broadcast(
name="DrumObsession",
start=start,
end=start + timedelta(hours=2),
)
now = start + timedelta(days=1)
rolled = api.roll_forward([entry], now)
assert len(rolled) == 1
assert rolled[0].start == start + timedelta(days=7)
assert rolled[0].start >= now
# an entry that is over and cannot be rolled into the future is dropped
far = start + timedelta(days=40)
assert api.roll_forward([entry], far) == []
def test_station_maps_the_title_and_finds_the_live_location() -> None:
now = datetime.now(timezone.utc)
slot = Broadcast(
name="Night Shift Over",
location="London, UK",
start=now - timedelta(minutes=30),
end=now + timedelta(minutes=30),
)
station = api.station_from_status(STATUS, [slot])
assert station is not None
assert station.id == "bassdrive"
assert station.dj == "Ben XO"
assert station.presenter == "live from London, UK"
assert station.now_playing is not None
assert station.now_playing.title == "Night Shift Over"
assert station.now_playing.artist == "Ben XO"
assert station.listeners == 38
assert station.format_label == "mp3 · 128k"
def test_a_title_without_a_live_marker_still_counts_when_the_slot_is_running() -> None:
now = datetime.now(timezone.utc)
status = {
"icestats": {
"source": [
{
"listenurl": "https://ice.bassdrive.net:443/stream",
"bitrate": 128,
"server_type": "audio/mpeg",
"listeners": 3,
"display-title": "Sohl Patrol",
}
]
}
}
slot = Broadcast(
name="Sohl Patrol",
location="White Plains, NY, USA",
start=now - timedelta(minutes=10),
end=now + timedelta(minutes=50),
)
live = api.live_broadcast(status, [slot], now)
assert live is not None and live.live is True
assert live.location == "White Plains, NY, USA"
def test_station_without_title_or_mounts_is_not_a_station() -> None:
assert api.station_from_status({"icestats": {}}) is None
def client_for(
status_payload,
page: str = "",
*,
status_code: int = 200,
page_code: int = 200,
) -> httpx.AsyncClient:
def handler(request: httpx.Request) -> httpx.Response:
if str(request.url).endswith("status-json.xsl"):
return httpx.Response(status_code, json=status_payload)
return httpx.Response(page_code, text=page)
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
async def songs_for(payload, station_id: str = "bassdrive"):
async with client_for(payload) as client:
return await api.fetch_songs(client, station_id)
@pytest.mark.asyncio
async def test_fetch_schedule_puts_the_live_show_first_and_caches_the_grid() -> None:
now = datetime.now(timezone.utc)
payload = {
"icestats": {
"source": [
{
"listenurl": "https://ice.bassdrive.net:443/stream",
"bitrate": 128,
"server_type": "audio/mpeg",
"listeners": 9,
"display-title": "Get Dipped LIVE",
}
]
}
}
# the grid is generated relative to now so the live slot is really running
local = now.astimezone(timezone(timedelta(hours=-5)))
start = local - timedelta(minutes=15)
end = local + timedelta(minutes=45)
page = SCHEDULE_PAGE.replace("2026-11-03T19:00:00-05:00", start.isoformat()).replace(
"2026-11-03T21:00:00-05:00", end.isoformat()
)
async with client_for(payload, page) as client:
broadcast_list = await api.fetch_schedule(client)
assert broadcast_list[0].live is True
assert broadcast_list[0].name == "Get Dipped"
assert broadcast_list[0].location == "Los Angeles, CA"
assert broadcast_list[0].start.hour == start.hour
assert all(not item.live for item in broadcast_list[1:])
# the grid was written for offline starts
assert api.load_cached_schedule()
# ... and the second fetch reuses the memo instead of the network
async with client_for(payload, page_code=500) as client:
assert await api.fetch_schedule(client)
@pytest.mark.asyncio
async def test_history_is_a_rolling_record_of_shows() -> None:
first = await songs_for(STATUS)
assert [song.title for song in first] == ["Night Shift Over"]
# same show: the first-seen time is kept
again = await songs_for(STATUS)
assert again[0].played_at == first[0].played_at
other = {
"icestats": {
"source": [
{
"listenurl": "https://ice.bassdrive.net:443/stream",
"bitrate": 128,
"server_type": "audio/mpeg",
"listeners": 1,
"display-title": "Sohl Patrol LIVE",
}
]
}
}
moved = await songs_for(other)
assert [song.title for song in moved] == ["Sohl Patrol", "Night Shift Over"]
def test_provider_describes_its_catalogue() -> None:
provider = BassdriveProvider()
assert provider.has_schedule is True
assert provider.bandcamp is False
assert len(provider.hero) == 3
assert "bright_cyan" in "".join(provider.hero)
assert provider.hero_lines == (
"BASSDRIVE",
"WORLDWIDE DRUM & BASS",
"LIVE SHOWS 24/7",
)
stations = provider.fallback_stations()
assert [station.id for station in stations] == ["bassdrive"]
urls = provider.fallback_urls(stations[0])
assert urls[0].startswith("https://") and len(urls) == 7
@pytest.mark.asyncio
async def test_cold_start_survives_an_unreachable_homepage() -> None:
"""`fetch_station` asks for the grid; a broken homepage must not stop it."""
async with client_for(STATUS, page_code=500) as client:
station = await api.fetch_station(client)
assert station.id == "bassdrive"
assert station.presenter == "live now" # live, but the location is unknown
assert station.now_playing is not None
+223
View File
@@ -0,0 +1,223 @@
"""The community-station providers: relays, status documents and schedules."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import httpx
import pytest
from beefy_player.providers import PROVIDERS
from beefy_player.providers.community import (
AnonradioProvider,
CashmereProvider,
DatafruitsProvider,
DublabProvider,
LotProvider,
LylProvider,
RefugeProvider,
RinseProvider,
dublab,
)
ICECAST = {
"icestats": {
"source": [
{
"listenurl": "https://anonradio.net:8443/anonradio",
"bitrate": 192,
"server_type": "audio/mpeg",
"listeners": 13,
"title": "repeating screwtape from 29-Nov-2023",
}
]
}
}
RADIO_CO = {
"status": "online",
"listeners": 41,
"current_track": {
"title": "Guest Show (r) - Ain Autavg",
"start_time": "2026-11-04T00:00:02+00:00",
"artwork_url": "https://images.radio.co/station.png",
},
}
def client_for(payload, status_code: int = 200) -> httpx.AsyncClient:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(status_code, json=payload)
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
def test_every_community_provider_builds_a_playable_station() -> None:
providers = (
AnonradioProvider,
DatafruitsProvider,
CashmereProvider,
LylProvider,
LotProvider,
RefugeProvider,
DublabProvider,
RinseProvider,
)
for cls in providers:
provider = cls()
stations = provider.fallback_stations()
assert stations, cls.id
for station in stations:
assert station.provider == provider.id
assert station.playlists, (cls.id, station.id)
assert all(url.startswith(("http://", "https://")) for url in provider.fallback_urls(station))
# every one of them spells its name in the same font
assert len(provider.hero) == 3, cls.id
def test_rinse_is_a_family_of_three_stations() -> None:
stations = RinseProvider().fallback_stations()
assert [station.id for station in stations] == ["rinse_uk", "kool", "swu"]
assert [station.title for station in stations] == ["Rinse UK", "Kool London", "SWU"]
for station in stations:
assert "admin.stream.rinse.fm/proxy/" in station.playlists[0]["url"]
assert station.format_label.endswith("128k")
def test_the_lot_streams_hls_which_the_player_owns() -> None:
from beefy_player.providers.base import is_direct_stream, is_playlist
url = LotProvider().fallback_urls(LotProvider().station())[0]
assert url.endswith(".m3u8")
assert is_playlist(url) is False
assert is_direct_stream(url) is True
@pytest.mark.asyncio
async def test_icecast_status_gives_the_station_what_is_on() -> None:
provider = AnonradioProvider()
async with client_for(ICECAST) as client:
station = (await provider.fetch_stations(client))[0]
assert station.listeners == 13
assert station.now_playing is not None
assert station.now_playing.title == "repeating screwtape from 29-Nov-2023"
@pytest.mark.asyncio
async def test_radio_co_status_gives_a_track_and_its_art() -> None:
provider = RefugeProvider()
async with client_for(RADIO_CO) as client:
station = (await provider.fetch_stations(client))[0]
history = await provider.fetch_history(client, "refuge")
assert station.listeners == 41
assert station.now_playing is not None
assert station.now_playing.album == ""
assert station.now_playing.art_url.endswith("station.png")
assert history[0].title == "Ain Autavg"
# the show's own start time is used, not "just now"
assert history[0].played_at == datetime(
2026, 11, 4, 0, 0, 2, tzinfo=timezone.utc
)
@pytest.mark.asyncio
async def test_history_is_rolling_and_keeps_the_first_sighting() -> None:
provider = AnonradioProvider()
async with client_for(ICECAST) as client:
first = await provider.fetch_history(client, "anonradio")
again = await provider.fetch_history(client, "anonradio")
assert len(first) == 1
assert again[0].played_at == first[0].played_at
@pytest.mark.asyncio
async def test_a_station_without_metadata_still_carries_on() -> None:
"""Rinse, LYL, The Lot and Cashmere publish no status document."""
provider = RinseProvider()
async with client_for({}, status_code=500) as client:
stations = await provider.fetch_stations(client)
history = await provider.fetch_history(client, "rinse_uk")
assert len(stations) == 3
assert all(station.now_playing is None for station in stations)
assert history == []
def test_dublab_schedule_parses_the_site_calendar() -> None:
payload = [
{
"startTime": "2026-11-03T16:00:00-08:00",
"endTime": "2026-11-03T18:00:00-08:00",
"summary": "LIVE // Version Sounds",
},
{
"startTime": "2026-11-03T18:00:00-08:00",
"endTime": "2026-11-03T20:00:00-08:00",
"summary": "PRE-REC // Sounds of Now",
},
]
shows = dublab.parse_schedule(payload)
assert [show.name for show in shows] == [
"LIVE // Version Sounds",
"PRE-REC // Sounds of Now",
]
assert shows[0].start == datetime(
2026, 11, 3, 16, tzinfo=timezone(timedelta(hours=-8))
)
assert dublab.parse_schedule({"nope": True}) == []
def test_the_running_slot_is_marked_live() -> None:
provider = DublabProvider()
now = datetime.now(timezone.utc)
running = dublab.Broadcast(
name="LIVE // Version Sounds",
start=now - timedelta(minutes=30),
end=now + timedelta(minutes=30),
)
later = dublab.Broadcast(
name="Later Show",
start=now + timedelta(hours=1),
end=now + timedelta(hours=2),
)
marked = provider._mark_live([running, later])
assert [item.live for item in marked] == [True, False]
assert marked[0].key() == running.key()
@pytest.mark.asyncio
async def test_dublab_history_follows_the_schedule() -> None:
provider = DublabProvider()
now = datetime.now(timezone.utc)
payload = [
{
"startTime": (now - timedelta(minutes=20)).isoformat(),
"endTime": (now + timedelta(minutes=40)).isoformat(),
"summary": "LIVE // Version Sounds",
}
]
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
await provider.fetch_schedule(client)
history = await provider.fetch_history(client, "dublab")
assert history and history[0].title == "LIVE // Version Sounds"
def test_every_catalogue_is_registered_under_its_slug() -> None:
assert set(PROVIDERS) == {
"somafm",
"tilderadio",
"bassdrive",
"radioparadise",
"dublab",
"rinse",
"refuge",
"lyl",
"lot",
"radiobrowser",
"cashmere",
"datafruits",
"anonradio",
}
+118
View File
@@ -0,0 +1,118 @@
"""The BBS frames: grey edges, and a short palette glow in two corners."""
from __future__ import annotations
from rich.text import Text
from beefy_player.palettes import PALETTES, brightness
from beefy_player.widgets import frame
#: The original ladder, used as a fixed reference in these tests.
STOPS = PALETTES["spectrum"]
#: 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]]:
return [
(line.plain[span.start : span.end], str(span.style)) for span in line.spans
]
def test_the_corner_gets_the_brightest_colour_of_the_fade() -> None:
ranks = [brightness(colour) for colour in RAMP]
assert ranks == sorted(ranks, reverse=True)
# spectrum's hot end is red, which is dimmer than the yellow before it:
# the corner must still be the bright one, not the ladder's last stop
assert STOPS[-1] != HOT
assert brightness(HOT) > brightness(STOPS[-1])
assert set(RAMP) <= set(STOPS)
assert frame.corner_fade(len(RAMP), STOPS) == frame.EDGE
def test_only_the_top_right_corner_fades_on_the_top_edge() -> None:
spans = styles_of(frame.top(30, stops=STOPS))
assert spans[0] == ("╭", frame.EDGE) # plain grey corner
# the fade runs into the top-right corner, brightest cell last
assert [style for _text, style in spans[-4:-1]] == [RAMP[3], RAMP[2], RAMP[1]]
assert spans[-1] == ("╮", f"bold {HOT}")
def test_only_the_bottom_left_corner_fades_on_the_bottom_edge() -> None:
spans = styles_of(frame.bottom(30, STOPS))
assert spans[0] == ("╰", f"bold {HOT}")
assert [style for _text, style in spans[1:4]] == [RAMP[1], RAMP[2], RAMP[3]]
assert spans[-1] == ("╯", frame.EDGE) # plain grey corner
def test_the_sides_glow_towards_their_own_corner_only() -> None:
# left column fades up from the bottom-left corner
assert [frame.side_style(row, 10, "left", STOPS) for row in range(10)] == [
frame.EDGE,
frame.EDGE,
frame.EDGE,
frame.EDGE,
frame.EDGE,
frame.EDGE,
frame.EDGE,
RAMP[3],
RAMP[2],
RAMP[1],
]
# right column fades down from the top-right corner
assert [frame.side_style(row, 10, "right", STOPS) for row in range(10)] == [
RAMP[1],
RAMP[2],
RAMP[3],
frame.EDGE,
frame.EDGE,
frame.EDGE,
frame.EDGE,
frame.EDGE,
frame.EDGE,
frame.EDGE,
]
def test_the_title_sits_after_the_left_fade() -> None:
line = frame.top(30, "STATIONS", STOPS)
assert line.plain.startswith("╭─ STATIONS ─")
assert "STATIONS" in line.plain
# a pane too narrow for the title drops it rather than truncating
assert "STATIONS" not in frame.top(10, "STATIONS", STOPS).plain
def test_wrap_frames_the_content_on_every_row() -> None:
content = [Text("hello"), Text("world")]
group = frame.wrap(content, 20, 6, "PANE", STOPS)
lines = [line.plain for line in group.renderables]
assert len(lines) == 6
assert lines[0].startswith("╭") and lines[-1].startswith("╰")
assert all(len(line) == 20 for line in lines)
assert all(line.startswith("│") and line.endswith("│") for line in lines[1:-1])
assert lines[1].startswith("│hello")
def test_content_wider_than_the_pane_is_trimmed_not_spilled() -> None:
"""A pane must never draw over its neighbour."""
long_line = Text("x" * 40)
group = frame.wrap([long_line], 20, 4, "PANE", STOPS)
lines = [line.plain for line in group.renderables]
assert all(len(line) == 20 for line in lines)
assert lines[1].endswith("│")
# the original renderable is left untouched
assert long_line.plain == "x" * 40
def test_a_short_pane_still_glows_in_its_bottom_left_corner() -> None:
"""The fade follows the pane's height, not the number of content rows."""
group = frame.wrap([Text("one line")], 20, 6, stops=STOPS)
lines = list(group.renderables)
# rows 1-4 are the content box; the fade climbs its last three rows
assert str(lines[1].spans[0].style) == frame.EDGE
assert [str(line.spans[0].style) for line in lines[2:5]] == [
RAMP[3],
RAMP[2],
RAMP[1],
]
+102 -4
View File
@@ -6,6 +6,9 @@ import types
import pytest
from rich.cells import cell_len
from beefy_player.providers import PROVIDERS
from beefy_player.providers.somafm import SomaFMProvider
from beefy_player.providers.wordmark import render
from beefy_player.widgets.header import HeaderBar
TIME = re.compile(r"\d\d:\d\d:\d\d$")
@@ -13,11 +16,17 @@ DATE = re.compile(r"[A-Z][a-z]{2} \d\d [A-Z][a-z]{2}$")
VOLUME = re.compile(r"vol \d+%$")
def render_header(width: int, height: int = 3) -> list[str]:
def render_header(
width: int, height: int = 3, provider=None
) -> list[str]:
"""Render the header without a running app, as plain text per line."""
fake = type("Fake", (), {})()
fake.app = types.SimpleNamespace(
playing_state="playing", volume=70, current=None, notice=None
playing_state="playing",
volume=70,
current=None,
notice=None,
provider=provider or SomaFMProvider(),
)
fake.size = types.SimpleNamespace(width=width, height=height)
return [line.plain.rstrip() for line in HeaderBar.render(fake).renderables]
@@ -34,8 +43,8 @@ def test_right_block_is_flush_right(width: int) -> None:
def test_ragged_middle_does_not_shift_the_right_block() -> None:
""""SOMA FM RADIO" and "SAN FRANCISCO" are shorter than
"LISTENER SUPPORTED"; the right block must not follow their length."""
""""TILDERADIO" is shorter than "STREAMED BY / FOR USERS OF THE"; the
right block must not follow the length of the middle lines."""
clock, date, volume = render_header(143)
assert cell_len(clock) == cell_len(date) == cell_len(volume) == 143
@@ -44,3 +53,92 @@ def test_right_block_hidden_on_narrow_terminals() -> None:
"""Below the threshold the right block is dropped, not squashed."""
lines = render_header(30)
assert all(":" not in line for line in lines)
def test_somafm_hero_spells_the_whole_name_and_its_taglines() -> None:
lines = render_header(143)
assert lines[0].startswith(render("SOMAFM")[0])
assert "SOMA FM RADIO" in lines[0]
assert "LISTENER SUPPORTED" in lines[1]
assert "SAN FRANCISCO" in lines[2]
def hero_styles(provider) -> list[str]:
return [
str(span.style)
for line in HeaderBar.render(_fake(provider)).renderables
for span in line.spans
]
def test_the_i_and_t_have_thin_stems() -> None:
"""Two-pixel stems make an I look like an H and a T look like a block."""
assert render("I") == ("▀█▀", " █", "▄█▄")
assert render("T") == ("▀█▀", " █", " █")
def test_the_font_still_spells_soma_the_way_the_original_logo_did() -> None:
"""The wordmark font is the SomaFM logo, so the old art must be reproduced."""
assert render("SOMA") == (
"▄▀▀▀ ▄▀▀▄ █▄ ▄█ ▄▀▀▄",
"▀▀▀▄ █ █ █ █ █ █▀▀█",
"▄▄▄▀ ▀▄▄▀ █ █ █ █",
)
@pytest.mark.parametrize("slug", sorted(PROVIDERS))
def test_the_caption_block_starts_on_one_column(slug: str) -> None:
"""Art rows are right-trimmed; a row ending in a blank cell must not pull
its caption a column to the left."""
provider = PROVIDERS[slug]
captions = provider.hero_lines
offset = max(0, (len(provider.hero) - len(captions)) // 2)
lines = render_header(200, height=len(provider.hero), provider=provider)
lines = lines[offset : offset + len(captions)]
columns = {
line.index(caption)
for line, caption in zip(lines, captions, strict=True)
}
assert len(columns) == 1, (slug, columns)
def test_every_catalogue_has_a_three_row_wordmark() -> None:
for slug, provider in PROVIDERS.items():
assert len(provider.hero) == 3, slug
assert provider.hero_lines, slug
def test_tilderadio_spells_its_own_name_in_the_default_colour() -> None:
provider = PROVIDERS["tilderadio"]
lines = render_header(143, height=len(provider.hero), provider=provider)
assert len(lines) == 3
assert "TILDERADIO" in lines[0] # the wordmark
assert "STREAMED BY / FOR USERS OF THE" in lines[1]
assert "TILDEVERSE" in lines[2]
# no colour of its own: the art inherits the terminal foreground
assert all("[" not in row for row in provider.hero)
assert not {"bright_cyan", "cyan"} & set(hero_styles(provider))
assert all("SOMA" not in line for line in lines)
def test_bassdrive_spells_its_name_in_its_own_two_blues() -> None:
provider = PROVIDERS["bassdrive"]
lines = render_header(143, height=len(provider.hero), provider=provider)
assert "BASSDRIVE" in lines[0]
assert "WORLDWIDE DRUM & BASS" in lines[1]
assert "LIVE SHOWS 24/7" in lines[2]
styles = hero_styles(provider)
assert "bright_cyan" in styles and "cyan" in styles
def _fake(provider):
fake = type("Fake", (), {})()
fake.app = types.SimpleNamespace(
playing_state="playing",
volume=70,
current=None,
notice=None,
provider=provider,
)
fake.size = types.SimpleNamespace(width=143, height=len(provider.hero) or 3)
return fake
+259
View File
@@ -0,0 +1,259 @@
"""The colour palettes: sixteen ladders of ANSI names, and the `c` key."""
from __future__ import annotations
import html
import re
import pytest
from beefy_player import config
from beefy_player.app import BeefyPlayerApp
from beefy_player.palettes import (
DEFAULT_PALETTE,
PALETTE_ORDER,
PALETTES,
brightness,
palette_stops,
)
from beefy_player.widgets import frame
from beefy_player.widgets.help import HelpScreen
from beefy_player.widgets.station_list import StationList
ANSI_NAMES = {
"black",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"white",
"bright_black",
"bright_red",
"bright_green",
"bright_yellow",
"bright_blue",
"bright_magenta",
"bright_cyan",
"bright_white",
}
def test_there_are_sixteen_palettes_of_ansi_names() -> None:
assert len(PALETTES) == 16
assert len(PALETTE_ORDER) == 16
assert len(set(PALETTE_ORDER)) == 16
assert DEFAULT_PALETTE in PALETTES
for name, stops in PALETTES.items():
assert len(stops) >= 3, name
assert set(stops) <= ANSI_NAMES, (name, set(stops) - ANSI_NAMES)
# a ladder, not a list: the quiet end must differ from the hot end
assert stops[0] != stops[-1], name
def test_a_broken_palette_name_falls_back_to_the_default() -> None:
assert palette_stops("mono") == PALETTES["mono"]
assert palette_stops(" MONO ") == PALETTES["mono"]
assert palette_stops("no-such-palette") == PALETTES[DEFAULT_PALETTE]
assert palette_stops(None) == PALETTES[DEFAULT_PALETTE]
def test_the_config_remembers_the_palette() -> None:
assert config.Config.load().palette == DEFAULT_PALETTE
config.Config(palette="ember").save()
assert config.Config.load().palette == "ember"
def test_an_unknown_palette_in_the_config_is_ignored() -> None:
config.CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
config.CONFIG_PATH.write_text('{"palette": "chartreuse"}')
assert config.Config.load().palette == DEFAULT_PALETTE
@pytest.mark.asyncio
async def test_the_c_key_cycles_and_remembers_the_palette() -> None:
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(100, 30)) as pilot:
await pilot.pause()
start = app.palette
assert start == DEFAULT_PALETTE
await pilot.press("c")
await pilot.pause()
expected = PALETTE_ORDER[(PALETTE_ORDER.index(start) + 1) % len(PALETTE_ORDER)]
assert app.palette == expected
assert app.config.palette == expected
assert "palette" in app.notice
# the choice survives a restart
config.Config(palette=app.palette).save()
assert config.Config.load().palette == expected
@pytest.mark.asyncio
async def test_the_pane_corners_follow_the_palette() -> None:
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(100, 30)) as pilot:
await pilot.pause()
pane = app.query_one(StationList)
app.palette = "mono"
mono = [str(span.style) for span in pane.render().renderables[0].spans]
app.palette = "ember"
ember = [str(span.style) for span in pane.render().renderables[0].spans]
assert mono != ember
assert any("bright_white" in style for style in mono)
# ember's hot end is white too, but its quiet end is not
assert any(PALETTES["ember"][-2] in style for style in ember)
@pytest.mark.asyncio
async def test_the_spectrum_rows_use_the_active_palette() -> None:
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(100, 30)) as pilot:
await pilot.pause()
spectrum = app.query_one("SpectrumPanel")
def colours(styles: list[str]) -> set[str]:
return {
style.replace("dim ", "").replace("bold ", "").strip()
for style in styles
}
app.palette = "mono"
spectrum.refresh()
await pilot.pause()
assert colours(spectrum._row_palette(12, 0, False)) <= set(PALETTES["mono"])
app.palette = "neon"
spectrum.refresh()
await pilot.pause()
assert colours(spectrum._row_palette(12, 0, False)) <= set(PALETTES["neon"])
def test_the_corner_fade_starts_at_the_brightest_stop() -> None:
stops = PALETTES["spectrum"]
ramp = frame.fade_ramp(stops)
assert frame.corner_fade(0, stops) == ramp[0]
assert frame.corner_fade(1, stops) == ramp[1]
# the fade is short: past the ramp it is just the border grey
assert frame.corner_fade(len(ramp), stops) == frame.EDGE
# spectrum's hot end is red, which is dimmer than the yellow before it;
# the corner still gets the bright colour
assert ramp[0] != stops[-1]
assert ramp[-1] == stops[-1]
def test_every_palette_fades_from_bright_to_dim() -> None:
"""No palette may put a brighter cell further from the corner."""
for name, stops in PALETTES.items():
ramp = frame.fade_ramp(stops)
assert ramp, name
ranks = [brightness(colour) for colour in ramp]
assert ranks == sorted(ranks, reverse=True), (name, ramp)
assert set(ramp) <= set(stops), name
@pytest.mark.asyncio
async def test_the_help_scrolls_when_the_terminal_is_short() -> None:
"""The help page is ~56 rows: it must be readable on a 24-row screen."""
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause()
await pilot.press("question_mark")
await pilot.pause()
body = app.screen.query_one("#help")
assert body.size.height < body.virtual_size.height
assert body.max_scroll_y > 0
assert body.scroll_y == 0
await pilot.press("pagedown")
await pilot.pause()
assert body.scroll_y > 0
await pilot.press("end")
await pilot.pause()
assert body.scroll_y == body.max_scroll_y
await pilot.press("home")
await pilot.pause()
assert body.scroll_y == 0
await pilot.press("x")
await pilot.pause()
assert not isinstance(app.screen, HelpScreen)
@pytest.mark.asyncio
async def test_a_tall_terminal_needs_no_scrolling() -> None:
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(120, 70)) as pilot:
await pilot.pause()
await pilot.press("question_mark")
await pilot.pause()
body = app.screen.query_one("#help")
assert body.max_scroll_y == 0
@pytest.mark.asyncio
async def test_the_help_lists_every_palette_and_marks_the_active_one() -> None:
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(120, 64)) as pilot:
await pilot.pause()
app.palette = "ocean"
await pilot.press("question_mark")
await pilot.pause()
text = html.unescape(app.export_screenshot()).replace("\xa0", " ")
text = re.sub(r"<[^>]+>", "", text)
assert "PALETTES" in text
assert "c cycles them" in text
for name in PALETTE_ORDER:
assert name in text, name
assert "● ocean" in text
# each palette shows a swatch of eight blocks
assert "████████" in text
@pytest.mark.asyncio
async def test_the_help_footer_attributes_the_build() -> None:
"""One line: the version and the project URL — no credits essay."""
from beefy_player import __version__
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(110, 70)) as pilot:
await pilot.pause()
await pilot.press("question_mark")
await pilot.pause()
body = app.screen.query_one("#help")
body.scroll_end(animate=False)
await pilot.pause()
text = html.unescape(app.export_screenshot()).replace("\xa0", " ")
text = re.sub(r"<[^>]+>", "", text)
assert f"beefy player {__version__}" in text
assert "https://tildeforge.dev/beefcarver/beefy-player" in text
# the old explanatory blurb is gone
assert "playerctl" not in text
assert "parec" not in text
assert "Stations and song history come from" not in text
@pytest.mark.parametrize("slug", ["bassdrive", "somafm"])
@pytest.mark.asyncio
async def test_the_help_has_no_pane_explainer(slug: str) -> None:
"""No catalogue gets a paragraph about its left pane."""
app = BeefyPlayerApp(provider=slug, autoplay=False, offline=True, audio=False)
async with app.run_test(size=(110, 60)) as pilot:
await pilot.pause()
await pilot.press("question_mark")
await pilot.pause()
text = html.unescape(app.export_screenshot()).replace("\xa0", " ")
text = re.sub(r"<[^>]+>", "", text)
assert "is a single stream" not in text
assert "multi-station catalogues" not in text
assert "CATALOGUES" in text
+2 -2
View File
@@ -2,13 +2,13 @@ from __future__ import annotations
import os
import queue
import sys
import time
from pathlib import Path
from beefy_player.player import Player
FAKE_MPV = '''#!/usr/bin/env python3
import json, os, socket, sys, time
FAKE_MPV = f"#!{sys.executable}\n" + '''import json, os, socket, sys, time
sock_path = None
volume = 70
+115
View File
@@ -0,0 +1,115 @@
"""The ON AIR metadata line: who is on the desk, and no lone artwork glyph."""
from __future__ import annotations
import types
import pytest
from beefy_player.app import BeefyPlayerApp
from beefy_player.models import Song, Station
from beefy_player.providers.somafm import SomaFMProvider
from beefy_player.providers.tilderadio import TilderadioProvider, api
from beefy_player.widgets.stage import StageInfo
def details(station: Station, song: Song | None, width: int = 90) -> list[str]:
app = types.SimpleNamespace(playing_state="playing", notice="")
return [line.plain for line in StageInfo()._details(app, station, song, width)]
def test_somafm_still_prefixes_a_dj_name() -> None:
station = Station(id="gs", title="Groove Salad", dj="SomaFM", provider="somafm")
lines = details(station, Song(artist="A", title="T", album="Album"))
assert any("Album" in line and "dj SomaFM" in line for line in lines)
def test_lone_artwork_glyph_is_not_drawn() -> None:
"""No album, no presenter: the ▣ would be a box with nothing to mark."""
station = Station(id="x", title="X", provider="somafm")
lines = details(station, Song(artist="A", title="T", art_url="https://a/1.jpg"))
assert not any("▣" in line for line in lines)
def test_artwork_glyph_rides_along_with_real_information() -> None:
station = Station(id="x", title="X", dj="Someone", provider="somafm")
lines = details(station, Song(artist="A", title="T", art_url="https://a/1.jpg"))
assert any("dj Someone" in line and "▣" in line for line in lines)
def test_tilderadio_shows_the_rotation_when_nothing_is_live() -> None:
payload = {
"station": {"name": "tilderadio", "shortcode": "tilderadio"},
"is_live": False,
}
station = api.station_from_now(payload)
assert station is not None
assert station.presenter_label == "auto dj · library rotation"
lines = details(station, Song(artist="A", title="T", art_url="https://a/1.jpg"))
assert any("auto dj · library rotation" in line for line in lines)
def test_tilderadio_shows_the_live_dj_instead() -> None:
payload = {
"station": {"name": "tilderadio", "shortcode": "tilderadio"},
"is_live": True,
"dj": "dokuja",
}
station = api.station_from_now(payload)
assert station is not None
assert station.presenter_label == "dj dokuja"
assert "auto dj" not in station.presenter_label
def test_presenter_label_falls_back_to_the_dj_then_nothing() -> None:
assert Station(id="a", title="A").presenter_label == ""
assert Station(id="a", title="A", dj="kexp").presenter_label == "dj kexp"
assert (
Station(id="a", title="A", dj="kexp", presenter="guest host").presenter_label
== "guest host"
)
def test_tilderadio_opts_out_of_bandcamp_lookups() -> None:
assert TilderadioProvider().bandcamp is False
assert SomaFMProvider().bandcamp is True
@pytest.mark.asyncio
async def test_no_bandcamp_worker_is_started_for_tilderadio(monkeypatch) -> None:
calls: list[tuple] = []
async def fake_find(client, artist, album, title):
calls.append((artist, album, title))
return "https://bandcamp.test/track"
monkeypatch.setattr("beefy_player.bandcamp.find_track", fake_find)
app = BeefyPlayerApp(
provider="tilderadio", autoplay=False, offline=True, audio=False
)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
station = app.current
assert station is not None
station.now_playing = Song(artist="A", title="T")
app._update_bandcamp()
await pilot.pause()
assert calls == []
assert station.now_playing.bandcamp_url == ""
@pytest.mark.asyncio
async def test_a_stale_bandcamp_link_is_cleared_when_the_catalogue_does_not_want_one() -> None:
app = BeefyPlayerApp(
provider="tilderadio", autoplay=False, offline=True, audio=False
)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
station = app.current
assert station is not None
station.now_playing = Song(
artist="A", title="T", bandcamp_url="https://bandcamp.test/old"
)
app._update_bandcamp()
assert station.now_playing.bandcamp_url == ""
+199 -1
View File
@@ -10,7 +10,10 @@ from beefy_player import config
from beefy_player.models import Song, Station
from beefy_player.providers import PROVIDERS, available_providers, get_provider
from beefy_player.providers.base import RadioProvider
from beefy_player.providers.bassdrive import BassdriveProvider
from beefy_player.providers.somafm import SomaFMProvider
from beefy_player.providers.tilderadio import TilderadioProvider
from beefy_player.widgets.header import HeaderBar
def rendered_text(app) -> str:
@@ -19,9 +22,26 @@ def rendered_text(app) -> str:
def test_registry_lists_somafm_as_the_default() -> None:
assert available_providers() == ("somafm",)
# the menu is alphabetical by name: aNONradio ... tilderadio
assert available_providers() == (
"anonradio",
"bassdrive",
"cashmere",
"datafruits",
"dublab",
"lyl",
"radiobrowser",
"radioparadise",
"refuge",
"rinse",
"somafm",
"lot",
"tilderadio",
)
assert isinstance(get_provider(), SomaFMProvider)
assert get_provider("somafm") is get_provider()
assert isinstance(get_provider("tilderadio"), TilderadioProvider)
assert isinstance(get_provider("bassdrive"), BassdriveProvider)
# a stale slug in the config file must not stop the app from starting
assert get_provider("no-such-catalogue") is get_provider()
@@ -128,3 +148,181 @@ def test_environment_variables_prefer_the_beefy_prefix(monkeypatch) -> None:
monkeypatch.delenv("SOMAFM_IMAGES")
assert config.env_var("IMAGES") == ""
def test_tilderadio_replaces_the_station_list_with_a_schedule() -> None:
provider = TilderadioProvider()
assert provider.has_schedule is True
# the SomaFM wordmark's slot now holds tilderadio's own art and motto
assert provider.hero and not provider.hero_gradient
assert provider.hero_lines == (
"TILDERADIO",
"STREAMED BY / FOR USERS OF THE",
"TILDEVERSE",
)
assert SomaFMProvider().has_schedule is False
class _CatalogueApp:
"""A headless app on a fixed catalogue, for the picker tests."""
def __init__(self, provider: str = "somafm") -> None:
from beefy_player.app import BeefyPlayerApp
self.app = BeefyPlayerApp(
provider=provider, autoplay=False, offline=True, audio=False
)
@pytest.mark.asyncio
async def test_p_opens_the_picker_and_switches_catalogue() -> None:
from beefy_player.widgets.providers import ProviderScreen
from beefy_player.widgets.schedule import SchedulePanel
from beefy_player.widgets.station_list import StationList
app = _CatalogueApp().app
async with app.run_test(size=(110, 40)) as pilot:
await pilot.pause()
assert app.provider.id == "somafm"
assert app.query_one(StationList).display is True
assert app.query_one(SchedulePanel).display is False
assert app.query_one(HeaderBar).styles.height.value == 4
await pilot.press("p")
await pilot.pause()
assert isinstance(app.screen, ProviderScreen)
# walk down the alphabetical menu from the current entry to tilderadio
slugs = available_providers()
steps = (slugs.index("tilderadio") - slugs.index("somafm")) % len(slugs)
for _ in range(steps):
await pilot.press("down")
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
await pilot.pause()
assert app.provider.id == "tilderadio"
assert app.config.provider == "tilderadio"
assert [station.id for station in app.stations] == ["tilderadio"]
# the left pane becomes the schedule; every hero is the same height
assert app.query_one(StationList).display is False
assert app.query_one(SchedulePanel).display is True
assert app.query_one(HeaderBar).styles.height.value == 4
assert "tilderadio" in app.title
@pytest.mark.asyncio
async def test_escape_closes_the_picker_without_switching() -> None:
app = _CatalogueApp().app
async with app.run_test(size=(110, 40)) as pilot:
await pilot.pause()
await pilot.press("p")
await pilot.pause()
await pilot.press("escape")
await pilot.pause()
assert app.provider.id == "somafm"
assert app.config.provider != "tilderadio"
@pytest.mark.asyncio
async def test_help_names_both_catalogues_and_the_switch_key() -> None:
app = _CatalogueApp().app
async with app.run_test(size=(120, 60)) as pilot:
await pilot.pause()
await pilot.press("question_mark")
await pilot.pause()
text = rendered_text(app)
assert "CATALOGUES" in text
assert "somafm" in text and "tilderadio" in text
assert "switch station catalogue" in text
def test_config_remembers_a_station_per_catalogue() -> None:
config.Config(last_stations={"somafm": "defcon", "tilderadio": "tilderadio"}).save()
loaded = config.Config.load()
assert loaded.last_station_for("somafm") == "defcon"
assert loaded.last_station_for("tilderadio") == "tilderadio"
# a catalogue nobody has tuned yet falls back to the legacy single field
assert loaded.last_station_for("no-such-catalogue") == loaded.last_station
@pytest.mark.asyncio
async def test_every_catalogue_header_is_the_same_height() -> None:
for slug in available_providers():
app = _CatalogueApp(slug).app
async with app.run_test(size=(110, 40)) as pilot:
await pilot.pause()
header = app.query_one(HeaderBar)
assert header.styles.height.value == 4, slug
lines = [line.plain for line in header.render().renderables]
assert any("STOPPED" in line or "PLAYING" in line for line in lines), slug
class _StubPlayer:
"""Just enough Player for a tune: records what mpv would have played."""
def __init__(self) -> None:
import queue
self.events: queue.Queue = queue.Queue()
self.played: list[list[str]] = []
self.volume: int | None = None
self.backend: str | None = None
def play(self, urls) -> None:
self.played.append(list(urls))
def set_volume(self, value: int) -> None:
self.volume = value
def stop(self) -> None:
pass
def toggle_pause(self) -> None:
pass
async def _switch_to(app, pilot, slug: str) -> None:
slugs = available_providers()
steps = (slugs.index(slug) - slugs.index(app.provider.id)) % len(slugs)
await pilot.press("p")
await pilot.pause()
for _ in range(steps):
await pilot.press("down")
await pilot.press("enter")
await pilot.pause()
await pilot.pause()
@pytest.mark.asyncio
async def test_switching_to_a_single_station_catalogue_tunes_it() -> None:
app = _CatalogueApp("somafm").app
async with app.run_test(size=(110, 40)) as pilot:
await pilot.pause()
stub = _StubPlayer()
app.player = stub
app.audio_enabled = True
await _switch_to(app, pilot, "tilderadio")
assert app.provider.id == "tilderadio"
assert app.current is not None and app.current.id == "tilderadio"
assert stub.played, "a one-station catalogue should start playing"
assert app.playing_state != "stopped"
@pytest.mark.asyncio
async def test_switching_to_a_multi_station_catalogue_waits_for_enter() -> None:
app = _CatalogueApp("tilderadio").app
async with app.run_test(size=(110, 40)) as pilot:
await pilot.pause()
stub = _StubPlayer()
app.player = stub
app.audio_enabled = True
await _switch_to(app, pilot, "somafm")
assert app.provider.id == "somafm"
assert len(app.stations) > 1
assert stub.played == [], "multi-station catalogues wait for enter"
+224
View File
@@ -0,0 +1,224 @@
"""Radio Browser: a directory of stations, not a station."""
from __future__ import annotations
import httpx
import pytest
from beefy_player.providers.radiobrowser import RadioBrowserProvider, api, fallback
ENTRY = {
"stationuuid": "96062a7b-0601-11e8-ae97-52543be04c81",
"name": "France Info",
"url_resolved": "http://direct.franceinfo.fr/live/franceinfo-midfi.mp3",
"url": "http://direct.franceinfo.fr/live/franceinfo-midfi.mp3",
"country": "France",
"language": "french",
"tags": "news,talk,politic",
"codec": "MP3",
"bitrate": 128,
"favicon": "https://www.francetvinfo.fr/icon.png",
}
def test_an_entry_becomes_a_station() -> None:
station = api.station_from_entry(ENTRY)
assert station is not None
assert station.id == ENTRY["stationuuid"]
assert station.title == "France Info"
assert station.description == "France · french"
assert station.genre_label == "news · talk · politic"
assert station.image.endswith("icon.png")
assert station.format_label == "mp3 · 128k"
assert station.provider == "radiobrowser"
def test_entries_without_a_stream_or_a_name_are_skipped() -> None:
assert api.station_from_entry({**ENTRY, "url_resolved": "", "url": ""}) is None
assert api.station_from_entry({**ENTRY, "name": " "}) is None
assert api.station_from_entry({**ENTRY, "stationuuid": ""}) is None
assert api.station_from_entry("not a dict") is None
def test_parse_dedupes_and_sorts_by_name() -> None:
payload = [
{**ENTRY, "stationuuid": "b", "name": "Zed FM"},
{**ENTRY, "stationuuid": "a", "name": "Alpha Radio"},
{**ENTRY, "stationuuid": "a", "name": "Alpha Radio"},
]
stations = api.parse_stations(payload)
assert [station.title for station in stations] == ["Alpha Radio", "Zed FM"]
assert api.parse_stations(None) == []
assert api.parse_stations({}) == []
def test_hls_streams_survive_as_direct_urls() -> None:
hls = {
**ENTRY,
"stationuuid": "hls",
"url_resolved": "https://example.test/live/index.m3u8?token=1",
"codec": "AAC",
"bitrate": 64,
}
station = api.station_from_entry(hls)
assert station is not None
assert station.playlists[0]["format"] == "aac"
assert station.format_label == "aac · 64k"
@pytest.mark.asyncio
async def test_search_stations_queries_all_searchable_fields() -> None:
entries = {
"name": {**ENTRY, "stationuuid": "name", "name": "Jazz Name"},
"tag": {**ENTRY, "stationuuid": "tag", "name": "Zed Jazz Tag"},
"country": {**ENTRY, "stationuuid": "country", "name": "Country Jazz"},
"language": {**ENTRY, "stationuuid": "language", "name": "Language Jazz"},
}
seen: list[dict[str, str]] = []
def handler(request: httpx.Request) -> httpx.Response:
params = dict(request.url.params)
seen.append(params)
field = next(name for name in api._SEARCH_FIELDS if name in params)
payload = [entries[field]]
if field in ("name", "tag"):
payload.append(ENTRY) # the same station may match twice
return httpx.Response(200, json=payload)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
stations = await api.search_stations(client, "jazz")
assert {station.id for station in stations} == {
ENTRY["stationuuid"],
"name",
"tag",
"country",
"language",
}
assert [params["limit"] for params in seen] == [
str(api.SEARCH_LIMIT)
] * len(api._SEARCH_FIELDS)
for params in seen:
assert params["hidebroken"] == "true"
assert params["order"] == "clickcount"
assert params["reverse"] == "true"
assert {next(name for name in api._SEARCH_FIELDS if name in params) for params in seen} == set(
api._SEARCH_FIELDS
)
@pytest.mark.asyncio
async def test_search_stations_ignores_a_failed_field(monkeypatch) -> None:
async def fake_fetch_json(client, url):
if "name=jazz" in url:
raise RuntimeError("name search is down")
return [ENTRY]
monkeypatch.setattr(api, "fetch_json", fake_fetch_json)
async with httpx.AsyncClient() as client:
stations = await api.search_stations(client, "jazz")
assert [station.id for station in stations] == [ENTRY["stationuuid"]]
@pytest.mark.asyncio
async def test_an_empty_search_does_not_hit_the_api() -> None:
def handler(request: httpx.Request) -> httpx.Response:
raise AssertionError("empty queries must not make a request")
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
assert await api.search_stations(client, " ") == []
@pytest.mark.asyncio
async def test_fetch_stations_caches_the_directory() -> None:
payload = [ENTRY, {**ENTRY, "stationuuid": "x", "name": "Another"}]
def handler(request: httpx.Request) -> httpx.Response:
params = dict(request.url.params)
assert params["hidebroken"] == "true"
assert params["limit"] == str(api.PAGE_SIZE)
assert params["offset"] == "0"
return httpx.Response(200, json=payload)
provider = RadioBrowserProvider()
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
stations = await provider.fetch_stations(client)
assert [station.title for station in stations] == ["France Info", "Another"]
assert [station.title for station in provider.load_cached_stations()] == [
"France Info",
"Another",
]
@pytest.mark.asyncio
async def test_fetch_station_page_continues_in_catalogue_order() -> None:
first = [
{**ENTRY, "stationuuid": f"id-{index}", "name": f"Station {index}"}
for index in range(api.PAGE_SIZE)
]
last = [{**ENTRY, "stationuuid": "last", "name": "Last Station"}]
seen: list[dict[str, str]] = []
def handler(request: httpx.Request) -> httpx.Response:
params = dict(request.url.params)
seen.append(params)
return httpx.Response(200, json=first if params["offset"] == "100" else last)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
page = await api.fetch_station_page(client, 100)
end = await api.fetch_station_page(client, 200)
assert [station.id for station in page.stations] == [
f"id-{index}" for index in range(api.PAGE_SIZE)
]
assert page.next_offset == 200
assert end.next_offset is None
assert [params["offset"] for params in seen] == ["100", "200"]
assert [params["limit"] for params in seen] == [str(api.PAGE_SIZE)] * 2
@pytest.mark.asyncio
async def test_fetch_stations_by_ids_uses_the_uuid_endpoint() -> None:
seen: list[dict[str, str]] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(dict(request.url.params))
return httpx.Response(200, json=[ENTRY])
station_id = ENTRY["stationuuid"]
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
stations = await api.fetch_stations_by_ids(
client, ["defcon", station_id, station_id]
)
assert [station.id for station in stations] == [station_id]
assert len(seen) == 1
assert seen[0]["uuids"] == station_id
@pytest.mark.asyncio
async def test_a_failed_fetch_does_not_kill_the_catalogue() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, json={})
provider = RadioBrowserProvider()
with pytest.raises(RuntimeError):
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
await provider.fetch_stations(client)
def test_the_bundled_directory_works_offline() -> None:
providers = fallback.fallback_stations()
assert len(providers) == 10
for station in providers:
assert station.playlists and station.provider == "radiobrowser"
assert station.playlists[0]["url"].startswith("https://")
def test_the_directory_offers_no_history_and_no_schedule() -> None:
provider = RadioBrowserProvider()
assert provider.has_schedule is False
assert provider.bandcamp is False
assert len(provider.hero) == 3
assert provider.hero_lines[0] == "RADIO BROWSER"
assert provider.load_cached_history("anything") == []
+90
View File
@@ -0,0 +1,90 @@
"""Radio Paradise: five mixes, one now-playing API, rolling history."""
from __future__ import annotations
import httpx
import pytest
from beefy_player.providers.radioparadise import RadioParadiseProvider, api
NOW = {
"time": 244,
"artist": "Eric Bibb",
"title": "Don't Ever Let Nobody Drag Your Spirit Down",
"album": "Painting Signs",
"year": "1999",
"cover": "https://img.radioparadise.com/covers/l/1.jpg",
}
def test_parse_now_keeps_the_cover_art() -> None:
song = api.parse_now(NOW)
assert song is not None
assert (song.artist, song.title) == ("Eric Bibb", NOW["title"])
assert song.album == "Painting Signs"
assert song.art_url.endswith("1.jpg")
assert api.parse_now({}) is None
assert api.parse_now(None) is None
def test_playlists_prefer_flac_and_carry_real_bitrates() -> None:
playlists = api.playlists(("flac", "aac-320", "mp3-320", "mp3-128"))
assert [entry["url"].rsplit("/", 1)[1] for entry in playlists] == [
"flac",
"aac-320",
"mp3-320",
"mp3-128",
]
assert [entry["quality"] for entry in playlists] == [
"highest",
"high",
"high",
"high",
]
assert playlists[0]["bitrate"] == "1411"
assert playlists[-1]["bitrate"] == "128"
def test_five_mixes_with_channel_numbers() -> None:
provider = RadioParadiseProvider()
stations = provider.fallback_stations()
assert [station.id for station in stations] == [
"main",
"mellow",
"rock",
"global",
"beyond",
]
assert api.CHANNEL_BY_STATION["beyond"] == 5
assert stations[0].format_label.startswith("flac")
# the alternates are FLAC-only relays
assert [entry["url"].rsplit("/", 1)[1] for entry in stations[1].playlists] == [
"mellow-flac"
]
@pytest.mark.asyncio
async def test_history_polls_the_right_channel_and_rolls() -> None:
seen: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(str(request.url))
return httpx.Response(200, json=NOW)
provider = RadioParadiseProvider()
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
first = await provider.fetch_history(client, "mellow")
again = await provider.fetch_history(client, "mellow")
assert any("chan=1" in url for url in seen)
assert len(first) == 1
assert again[0].key() == first[0].key()
assert again[0].played_at == first[0].played_at # first sighting kept
assert provider.load_cached_stations()[0].title == "The Main Mix"
@pytest.mark.asyncio
async def test_an_unknown_station_has_no_history() -> None:
provider = RadioParadiseProvider()
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, json=NOW))) as client:
assert await provider.fetch_history(client, "no-such-mix") == []
+188
View File
@@ -0,0 +1,188 @@
"""tilderadio's schedule: parsing, the live slot, caching and the pane."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import httpx
import pytest
from beefy_player.app import BeefyPlayerApp
from beefy_player.models import Broadcast
from beefy_player.providers.tilderadio import api
from beefy_player.widgets.schedule import SchedulePanel, _upcoming_rows
def iso(moment: datetime) -> str:
return moment.isoformat()
def test_parse_schedule_reads_the_documented_feed() -> None:
payload = {
"schedule": [
{
"name": "controlfreak",
"slug": "controlfreak",
"start": "2026-11-04T02:00:00+00:00",
"end": "2026-11-04T03:00:00+00:00",
"start_ts": 1793757600,
"end_ts": 1793761200,
},
{
"name": "pa2",
"slug": "pa2",
"start": "2026-11-04T14:00:00+00:00",
"end": "2026-11-04T15:00:00+00:00",
"start_ts": 1793800800,
"end_ts": 1793804400,
},
]
}
shows = api.parse_schedule(payload)
assert [show.name for show in shows] == ["controlfreak", "pa2"]
assert shows[0].start == datetime(2026, 11, 4, 2, tzinfo=timezone.utc)
assert shows[0].end == datetime(2026, 11, 4, 3, tzinfo=timezone.utc)
assert not shows[0].live
# junk is dropped rather than crashing the pane
assert api.parse_schedule({"schedule": [None, {}, {"name": "no start"}]}) == []
assert api.parse_schedule(None) == []
def test_live_broadcast_only_when_someone_is_on_air() -> None:
payload = {
"is_live": False,
"dj": None,
"broadcast_start": None,
}
assert api.live_broadcast(payload) is None
payload = {
"is_live": True,
"dj": "dokuja",
"broadcast_start": 1793599228,
}
live = api.live_broadcast(payload)
assert live is not None
assert (live.name, live.dj, live.live) == ("dokuja", "dokuja", True)
assert live.start is not None
def client_for(now_payload, schedule_payload, status: int = 200) -> httpx.AsyncClient:
def handler(request: httpx.Request) -> httpx.Response:
if str(request.url).endswith("/api/now/"):
return httpx.Response(200, json=now_payload)
if str(request.url).endswith("/api/schedule/"):
return httpx.Response(status, json=schedule_payload)
return httpx.Response(404, json={})
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
@pytest.mark.asyncio
async def test_fetch_schedule_puts_the_live_show_first_and_drops_the_past() -> None:
now = datetime.now(timezone.utc)
now_payload = {
"is_live": True,
"dj": "dokuja",
"broadcast_start": iso(now - timedelta(minutes=25)),
}
schedule_payload = {
"schedule": [
{
"name": "yesterday",
"start": iso(now - timedelta(days=1)),
"end": iso(now - timedelta(days=1) + timedelta(hours=1)),
},
{
"name": "controlfreak",
"start": iso(now + timedelta(hours=2)),
"end": iso(now + timedelta(hours=3)),
},
]
}
async with client_for(now_payload, schedule_payload) as client:
broadcasts = await api.fetch_schedule(client)
assert broadcasts[0].live and broadcasts[0].name == "dokuja"
assert [item.name for item in broadcasts[1:]] == ["controlfreak"]
# ... and the schedule survives a dead endpoint
async with client_for({"is_live": False}, {}, status=500) as client:
offline = await api.fetch_schedule(client)
assert [item.name for item in offline] == ["controlfreak"]
def broadcast(name: str, minutes: int, *, live: bool = False) -> Broadcast:
start = datetime.now(timezone.utc) + timedelta(minutes=minutes)
return Broadcast(
name=name, dj=name, start=start, end=start + timedelta(hours=1), live=live
)
async def panel_lines(broadcasts: list[Broadcast], size=(100, 40)) -> list[str]:
app = BeefyPlayerApp(provider="tilderadio", offline=True, audio=False)
async with app.run_test(size=size) as pilot:
await pilot.pause()
app.schedule = broadcasts
app.schedule_loaded = True
panel = app.query_one(SchedulePanel)
return [line.plain for line in panel.render().renderables]
@pytest.mark.asyncio
async def test_panel_shows_the_transmitter_and_what_is_next() -> None:
lines = await panel_lines(
[broadcast("dokuja", -20, live=True), broadcast("controlfreak", 90)]
)
text = "\n".join(lines)
assert "ON AIR" in text and "dokuja" in text
assert "since " in text and "until " in text
assert "UPCOMING" in text
assert "controlfreak" in text
@pytest.mark.asyncio
async def test_panel_says_auto_dj_when_nothing_is_live() -> None:
lines = await panel_lines([broadcast("pa2", 120)])
text = "\n".join(lines)
assert "AUTO DJ" in text
assert "library rotation" in text
assert "ON AIR" not in text
@pytest.mark.asyncio
async def test_panel_handles_an_empty_schedule() -> None:
assert "nothing scheduled" in "\n".join(await panel_lines([]))
def test_upcoming_rows_count_the_shows_they_had_to_hide() -> None:
many = [broadcast(f"show {index}", 60 * (index + 1)) for index in range(30)]
lines = [line.plain for line in _upcoming_rows(many, 34, 10)]
assert len(lines) <= 10
assert any("more" in line for line in lines)
# every show is either listed or counted in the "… +N more" line
listed = sum(1 for line in lines if "show " in line)
hidden = int(lines[-1].split("+")[1].split()[0])
assert listed + hidden == 30
@pytest.mark.asyncio
async def test_long_show_names_stay_inside_the_pane() -> None:
"""Regression: the schedule's row maths used to overshoot by two cells."""
app = BeefyPlayerApp(provider="tilderadio", offline=True, audio=False)
async with app.run_test(size=(80, 30)) as pilot:
await pilot.pause()
now = datetime.now(timezone.utc)
app.schedule = [
Broadcast(
name="A Very Long Show Name That Would Never Fit In The Pane",
start=now + timedelta(hours=index + 1),
end=now + timedelta(hours=index + 2),
)
for index in range(6)
]
app.schedule_loaded = True
panel = app.query_one(SchedulePanel)
rows = [line.plain for line in panel.render().renderables]
assert all(len(row) == panel.size.width for row in rows)
assert all(row.startswith(("│", "╭", "╰")) for row in rows)
+4 -4
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import sys
import time
from pathlib import Path
@@ -8,8 +9,7 @@ import numpy as np
from beefy_player import spectrum as spectrum_module
from beefy_player.spectrum import Frame, SpectrumEngine
PCM_SCRIPT = """#!/usr/bin/env python3
import math, struct, sys, time
PCM_SCRIPT = f"#!{sys.executable}\n" + """import math, struct, sys, time
sr = 44100
samples = [int(0.6 * 32767 * math.sin(2 * math.pi * 1000 * i / sr)) for i in range(4096)]
chunk = struct.pack("<%dh" % len(samples), *samples)
@@ -303,7 +303,7 @@ def test_tap_audio_is_downmixed_and_retuned() -> None:
def test_dead_monitor_falls_back_instead_of_flat_bars(tmp_path) -> None:
script = tmp_path / "silent-parec"
script.write_text(
"#!/usr/bin/env python3\n"
f"#!{sys.executable}\n"
"import sys\n"
"block = b'\\0' * 4096\n"
"for _ in range(1000):\n"
@@ -333,7 +333,7 @@ def test_monitor_survives_silence_after_audio(tmp_path, monkeypatch) -> None:
# replaced by the drifting stream tap) for the rest of the session.
script = tmp_path / "parec"
script.write_text(
"#!/usr/bin/env python3\n"
f"#!{sys.executable}\n"
"import math, struct, sys, time\n"
"sr = 44100\n"
"n = 2048\n"
+104
View File
@@ -0,0 +1,104 @@
"""The station pane: paging, the position readout and the scrollbar."""
from __future__ import annotations
import re
import pytest
from beefy_player.app import BeefyPlayerApp
from beefy_player.widgets.station_list import StationList
def app_for(provider: str, *, offline: bool = True) -> BeefyPlayerApp:
return BeefyPlayerApp(provider=provider, autoplay=False, offline=offline, audio=False)
def test_thumb_geometry() -> None:
# nothing to scroll: the whole track is the thumb
assert StationList.thumb(10, 10, 0) == set(range(10))
# top of a long list
top = StationList.thumb(10, 100, 0)
assert 0 in top and len(top) >= 1
# bottom of the same list ends at the last row
bottom = StationList.thumb(10, 100, 90)
assert 9 in bottom
# mid-list sits between the two
middle = StationList.thumb(10, 100, 45)
assert min(middle) > min(top) and max(middle) < max(bottom)
@pytest.mark.asyncio
async def test_paging_moves_a_screen_and_back() -> None:
app = app_for("somafm")
async with app.run_test(size=(100, 26)) as pilot:
await pilot.pause()
pane = app.query_one(StationList)
page = pane.size.height - 1
start = app.index
await pilot.press("pagedown")
await pilot.pause()
assert app.index == min(start + page, len(app.view) - 1)
await pilot.press("pageup")
await pilot.pause()
assert app.index == start
# the bottom of the list clamps instead of wrapping
await pilot.press("shift+g", "pagedown")
await pilot.pause()
assert app.index == len(app.view) - 1
@pytest.mark.asyncio
async def test_the_title_reads_out_the_position() -> None:
app = app_for("somafm")
async with app.run_test(size=(100, 26)) as pilot:
await pilot.pause()
pane = app.query_one(StationList)
assert re.fullmatch(r"STATIONS · \d+/\d+", pane.border_title)
await pilot.press("down")
await pilot.pause()
first, total = pane.border_title.split(" · ")[1].split("/")
assert int(total) == len(app.view)
assert int(first) == app.index + 1
@pytest.mark.asyncio
async def test_the_scrollbar_appears_only_when_the_list_overflows() -> None:
app = app_for("somafm")
async with app.run_test(size=(100, 26)) as pilot:
await pilot.pause()
pane = app.query_one(StationList)
rows = [line.plain for line in pane.render().renderables]
assert rows[0].startswith("╭") and rows[-1].startswith("╰")
content = rows[1:-1]
# thumb only, in the frame grey: blank cells elsewhere, no rail
assert all(row[-2] in "▐ " for row in content)
assert any(row[-2] == "▐" for row in content)
app = app_for("rinse") # three stations, no scrolling
async with app.run_test(size=(100, 26)) as pilot:
await pilot.pause()
pane = app.query_one(StationList)
rows = [line.plain for line in pane.render().renderables]
assert rows[0].startswith("╭") and rows[-1].startswith("╰")
assert sum("Rinse" in row or "Kool" in row or "SWU" in row for row in rows) == 3
# three stations fit: no thumb in the content rows
assert not any(row[-2] == "▐" for row in rows[1:-1])
@pytest.mark.asyncio
async def test_the_filter_keeps_the_readout_in_step() -> None:
app = app_for("somafm")
async with app.run_test(size=(100, 26)) as pilot:
await pilot.pause()
await pilot.press("slash")
await pilot.pause()
app.query_one("SearchInput").value = "drone"
await pilot.pause()
pane = app.query_one(StationList)
assert pane.border_title.startswith("STATIONS · /drone · ")
filtered = len(app.view)
assert pane.border_title.endswith(f"/{filtered}")
+9 -2
View File
@@ -7,6 +7,7 @@ import pytest
from beefy_player.app import BeefyPlayerApp
from beefy_player.widgets.header import HeaderBar
from beefy_player.widgets.schedule import SchedulePanel
from beefy_player.widgets.search import SearchInput
from beefy_player.widgets.spectrum import SpectrumPanel
from beefy_player.widgets.stage import StagePanel
@@ -42,6 +43,7 @@ async def test_backgrounds_use_terminal_default_and_borders_are_ansi() -> None:
HeaderBar,
SearchInput,
StationList,
SchedulePanel,
StagePanel,
SpectrumPanel,
StatusBar,
@@ -50,8 +52,13 @@ async def test_backgrounds_use_terminal_default_and_borders_are_ansi() -> None:
assert widget.styles.background.ansi == -1, widget_type
assert widget.styles.color.ansi == -1, widget_type
border = app.query_one(StationList).styles.border.top
assert border[1].ansi is not None # an ANSI palette index, not RGB
# panes draw their own frames now: dim edges, gradient corners
pane = app.query_one(StationList)
assert pane.styles.border.top[0] == "" # no framework border any more
framed = pane.render().renderables
corners = [str(span.style) for span in framed[0].spans]
assert framed[0].plain.startswith("╭")
assert any(style.strip() for style in corners)
await pilot.press("slash")
await pilot.pause()
+249
View File
@@ -0,0 +1,249 @@
"""tilderadio: parsing, caching and the offline fallback.
The payloads below are trimmed copies of real ``/api/now/`` and AzuraCast
``/api/nowplaying/<station>`` responses, so the tests keep working when the
station is off air.
"""
from __future__ import annotations
import httpx
import pytest
from beefy_player.providers.tilderadio import TilderadioProvider, api, fallback
NOW_PAYLOAD = {
"api_version": 1,
"status": "ok",
"updated_at": 1793741211,
"available": True,
"station": {
"id": 1,
"name": "tilderadio",
"shortcode": "tilderadio",
"listen_url": "https://azuracast.tilderadio.org/listen/tilderadio/radio.ogg",
},
"is_live": False,
"broadcast_start": None,
"dj": None,
"listeners": 5,
"now_playing": {
"artist": None,
"title": "souldies-dramatics-feel it",
"text": "souldies-dramatics-feel it",
"art": "https://azuracast.tilderadio.org/api/station/tilderadio/art/199f03",
"played_at": 1793741079,
"duration": 192,
},
"history": [
{
"artist": None,
"title": "souldies-everytime i see a pretty lady-gemini",
"text": "souldies-everytime i see a pretty lady-gemini",
"art": "https://azuracast.tilderadio.org/api/station/tilderadio/art/5e46a9",
"played_at": 1793740639,
},
{
"artist": "TILDE PROMO (station)",
"title": "owly (bots 3)",
"text": "TILDE PROMO (station) - owly (bots 3)",
"art": "https://azuracast.tilderadio.org/api/station/tilderadio/art/54ac9f",
"played_at": 1793740629,
},
],
}
MOUNTS_PAYLOAD = {
"station": {
"id": 1,
"name": "tilderadio",
"shortcode": "tilderadio",
"listen_url": "https://azuracast.tilderadio.org/listen/tilderadio/radio.ogg",
"mounts": [
{
"name": "/radio.mp3 (192kbps MP3)",
"url": "https://azuracast.tilderadio.org/listen/tilderadio/radio.mp3",
"bitrate": 192,
"format": "mp3",
"is_default": False,
},
{
"name": "/320k.mp3 (320kbps MP3)",
"url": "https://azuracast.tilderadio.org/listen/tilderadio/320k.mp3",
"bitrate": 320,
"format": "mp3",
"is_default": False,
},
{
"name": "192kbps OGG Vorbis",
"url": "https://azuracast.tilderadio.org/listen/tilderadio/radio.ogg",
"bitrate": 192,
"format": "ogg",
"is_default": True,
},
],
}
}
def client_for(*responses: tuple[str, int, object]) -> httpx.AsyncClient:
"""An httpx client that answers from ``(url_prefix, status, payload)``."""
table = list(responses)
def handler(request: httpx.Request) -> httpx.Response:
for prefix, status, payload in table:
if str(request.url).startswith(prefix):
return httpx.Response(status, json=payload)
return httpx.Response(404, json={})
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
def test_provider_describes_its_catalogue() -> None:
provider = TilderadioProvider()
assert provider.id == "tilderadio"
assert provider.brand == "TILDE RADIO"
assert provider.station_source == "tilderadio.org/api/now/"
assert "tilderadio.org" in provider.user_agent
stations = provider.fallback_stations()
assert len(stations) == 1
station = stations[0]
assert station.uid == "tilderadio:tilderadio"
assert all("tilderadio.org" in url for url in provider.fallback_urls(station))
# the bundled list still advertises a real bitrate, not a URL guess
assert station.format_label == "mp3 · 320k"
def test_station_from_now_reads_the_single_station() -> None:
station = api.station_from_now(NOW_PAYLOAD, fallback.fallback_playlists())
assert station is not None
assert station.id == "tilderadio"
assert station.provider == "tilderadio"
assert station.listeners == 5
assert station.dj == "" # nobody is on air
assert station.genre_label == "community · eclectic"
assert station.now_playing is not None
assert station.now_playing.title == "souldies-dramatics-feel it"
assert station.now_playing.art_url.endswith("199f03")
assert station.last_playing == "souldies-dramatics-feel it"
def test_live_dj_is_only_named_when_someone_is_on_air() -> None:
live = dict(NOW_PAYLOAD, is_live=True, dj="ffog")
station = api.station_from_now(live)
assert station is not None and station.dj == "ffog"
# the AzuraCast flavour of the same field wins when present
nested = dict(NOW_PAYLOAD, live={"is_live": True, "name": "controlfreak"})
station = api.station_from_now(nested)
assert station is not None and station.dj == "controlfreak"
def test_artist_is_split_out_of_the_text_blob() -> None:
song = api.song_from_track(
{
"artist": "TILDE PROMO (station)",
"title": "owly (bots 3)",
"text": "TILDE PROMO (station) - owly (bots 3)",
"played_at": 1793740629,
}
)
assert song is not None
assert (song.artist, song.title) == ("TILDE PROMO (station)", "owly (bots 3)")
# no artist field: the "Artist - Title" text has to carry it
song = api.song_from_track(
{"title": "Kenny Ken", "text": "Kenny Ken - Amazon Jungle Collection 3"}
)
assert song is not None
assert (song.artist, song.title) == ("Kenny Ken", "Amazon Jungle Collection 3")
def test_songs_from_now_puts_the_current_track_first_and_dedupes() -> None:
payload = dict(NOW_PAYLOAD)
# a feed that repeats the current track in its own history
payload["history"] = [NOW_PAYLOAD["now_playing"], *NOW_PAYLOAD["history"]]
songs = api.songs_from_now(payload)
assert len(songs) == 3
assert songs[0].title == "souldies-dramatics-feel it"
assert songs[1].title == "souldies-everytime i see a pretty lady-gemini"
played = [song.played_at for song in songs]
assert played == sorted(played, reverse=True)
def test_mounts_become_playlists_best_first() -> None:
playlists = api.playlists_from_mounts(MOUNTS_PAYLOAD)
assert [entry["bitrate"] for entry in playlists] == ["192", "320", "192"]
station = api.station_from_now(
{**NOW_PAYLOAD, "now_playing": None}, playlists
)
assert station is not None
assert station.format_label == "mp3 · 320k"
assert station.sorted_playlists()[0]["url"].endswith("320k.mp3")
assert [entry["quality"] for entry in playlists] == ["high", "highest", "high"]
def test_station_without_mounts_falls_back_to_the_listen_url() -> None:
station = api.station_from_now(NOW_PAYLOAD)
assert station is not None
assert len(station.playlists) == 1
assert station.playlists[0]["url"].endswith("radio.ogg")
assert station.format_label == "ogg · 128k" # guessed, the feed didn't say
@pytest.mark.asyncio
async def test_fetch_station_caches_what_it_learned() -> None:
async with client_for(
("https://tilderadio.org/api/now/", 200, NOW_PAYLOAD),
("https://azuracast.tilderadio.org/api/nowplaying/", 200, MOUNTS_PAYLOAD),
) as client:
station = await api.fetch_station(client)
assert station.listeners == 5
assert station.format_label == "mp3 · 320k"
cached = api.load_cached_station()
assert len(cached) == 1
assert cached[0].id == "tilderadio"
assert cached[0].now_playing is not None
assert cached[0].format_label == "mp3 · 320k"
@pytest.mark.asyncio
async def test_fetch_station_survives_an_unreachable_azuracast() -> None:
async with client_for(("https://tilderadio.org/api/now/", 200, NOW_PAYLOAD)) as client:
station = await api.fetch_station(client)
# no mounts -> the documented listen_url is still a usable stream
assert station.playlists[0]["url"].endswith("radio.ogg")
@pytest.mark.asyncio
async def test_history_falls_back_to_the_cache_when_the_feed_is_down() -> None:
async with client_for(
("https://tilderadio.org/api/now/", 200, NOW_PAYLOAD)
) as client:
songs = await api.fetch_songs(client, "tilderadio")
assert len(songs) == 3
async with client_for(
("https://tilderadio.org/api/now/", 500, {})
) as client:
cached = await api.fetch_songs(client, "tilderadio")
assert [song.title for song in cached] == [song.title for song in songs]
def test_station_cache_is_per_provider() -> None:
from beefy_player import config
assert config.channels_cache_path("tilderadio").name == "channels-tilderadio.json"
# SomaFM keeps its historical file name, so upgrades don't lose the cache
assert config.channels_cache_path("somafm") == config.CHANNELS_CACHE
def test_fallback_station_is_usable_offline() -> None:
provider = TilderadioProvider()
stations = provider.fallback_stations()
assert [station.id for station in stations] == ["tilderadio"]
station = stations[0]
assert station.listeners == 0
assert station.now_playing is None
assert {entry["format"] for entry in station.playlists} == {"mp3", "ogg"}
+264
View File
@@ -0,0 +1,264 @@
#!/usr/bin/env python3
"""Turn a logo into half-block ANSI art for the header hero.
The header hero is a handful of text rows, so an image has to survive a brutal
downsample: two pixels per cell (upper/lower half block), one ANSI colour per
half. Colours are quantised to the stock 16-colour ANSI palette on purpose —
the rest of the UI never emits RGB, and a hero built from palette names still
reads as the station's art in any terminal theme.
Usage:
python tools/ansi_art.py logo.png --cols 22 --rows 7
python tools/ansi_art.py --url https://tilderadio.org/logos/tilderadio.png \\
--cols 22 --rows 7 --module beefy_player/providers/tilderadio/hero.py
``--preview out.png`` draws what the cells will look like, which is much easier
to judge than a wall of ▀ and ▄.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from PIL import Image, ImageDraw
#: The stock xterm colours the names below resolve to in a terminal.
ANSI16: dict[str, tuple[int, int, int]] = {
"black": (0, 0, 0),
"red": (170, 0, 0),
"green": (0, 170, 0),
"yellow": (170, 85, 0),
"blue": (0, 0, 170),
"magenta": (170, 0, 170),
"cyan": (0, 170, 170),
"white": (170, 170, 170),
"bright_black": (85, 85, 85),
"bright_red": (255, 85, 85),
"bright_green": (85, 255, 85),
"bright_yellow": (255, 255, 85),
"bright_blue": (85, 85, 255),
"bright_magenta": (255, 85, 255),
"bright_cyan": (85, 255, 255),
"bright_white": (255, 255, 255),
}
#: Below this alpha a half-cell counts as empty (terminal background).
ALPHA_CUTOFF = 110
#: Ink weaker than this is treated as fringe when cropping the image.
CROP_CUTOFF = 96
def nearest_colour(rgb: tuple[int, int, int]) -> str:
best, best_distance = "white", float("inf")
for name, reference in ANSI16.items():
distance = sum((a - b) ** 2 for a, b in zip(rgb, reference, strict=True))
if distance < best_distance:
best, best_distance = name, distance
return best
def key_white(image: Image.Image, tolerance: int) -> Image.Image:
"""Make near-white pixels transparent.
A logo exported on a white background (or one whose anti-aliasing bleeds
white) otherwise survives the downsample as a glaring box.
"""
image = image.copy()
pixels = image.load()
width, height = image.size
threshold = 255 - max(0, tolerance)
for y in range(height):
for x in range(width):
red, green, blue, alpha = pixels[x, y]
if alpha and min(red, green, blue) >= threshold:
pixels[x, y] = (red, green, blue, 0)
return image
def resize_premultiplied(image: Image.Image, size: tuple[int, int]) -> Image.Image:
"""Resize in premultiplied alpha, so transparent pixels cannot bleed in.
Plain ``Image.resize`` averages the RGB of fully transparent pixels (often
white) with the surrounding ink, which is how a clean logo turns into a
grey haze at 20x14.
"""
import numpy as np
data = np.asarray(image.convert("RGBA"), dtype=np.float32)
alpha = data[..., 3:4] / 255.0
premultiplied = np.concatenate([data[..., :3] * alpha, data[..., 3:4]], axis=-1)
small = Image.fromarray(premultiplied.astype(np.uint8), "RGBA").resize(
size, Image.LANCZOS
)
out = np.asarray(small, dtype=np.float32)
out_alpha = out[..., 3:4] / 255.0
rgb = out[..., :3] / np.maximum(out_alpha, 1e-6)
return Image.fromarray(
np.concatenate([np.clip(rgb, 0, 255), out[..., 3:4]], axis=-1).astype(np.uint8),
"RGBA",
)
def load_image(
path: Path | None, url: str | None, key_white_background: bool = False
) -> Image.Image:
if path is None and url:
import httpx
response = httpx.get(url, follow_redirects=True, timeout=30.0)
response.raise_for_status()
from io import BytesIO
image = Image.open(BytesIO(response.content))
elif path is not None:
image = Image.open(path)
else: # pragma: no cover - argparse enforces one of the two
raise SystemExit("pass an image path or --url")
image = image.convert("RGBA")
if key_white_background:
image = key_white(image, 24)
# Trim the transparent margin: the art should fill the box it is given.
opaque = image.getchannel("A").point(lambda value: 255 if value > CROP_CUTOFF else 0)
box = opaque.getbbox()
return image.crop(box) if box else image
def art_rows(image: Image.Image, cols: int, rows: int) -> list[list[tuple[str, str, str]]]:
"""(glyph, foreground, background) per cell, two source pixels per cell."""
small = resize_premultiplied(image, (cols, rows * 2))
pixels = small.load()
grid: list[list[tuple[str, str, str]]] = []
for row in range(rows):
cells: list[tuple[str, str, str]] = []
for column in range(cols):
top = pixels[column, row * 2]
bottom = pixels[column, row * 2 + 1]
top_ink = top[3] >= ALPHA_CUTOFF
bottom_ink = bottom[3] >= ALPHA_CUTOFF
if not top_ink and not bottom_ink:
cells.append((" ", "", ""))
elif top_ink and bottom_ink:
cells.append(("▀", nearest_colour(top[:3]), nearest_colour(bottom[:3])))
elif top_ink:
cells.append(("▀", nearest_colour(top[:3]), ""))
else:
cells.append(("▄", nearest_colour(bottom[:3]), ""))
grid.append(cells)
return grid
def markup(grid: list[list[tuple[str, str, str]]]) -> list[str]:
"""One rich-markup string per row, run-length encoded by style."""
lines: list[str] = []
for cells in grid:
line: list[str] = []
index = 0
while index < len(cells):
glyph, foreground, background = cells[index]
if not background and glyph == " ":
line.append(" ")
index += 1
continue
run = [glyph]
index += 1
while index < len(cells) and cells[index] == (glyph, foreground, background):
run.append(cells[index][0])
index += 1
style = " ".join(
part for part in (foreground, f"on {background}" if background else "") if part
)
body = "".join(run)
line.append(f"[{style}]{body}[/]" if style else body)
lines.append("".join(line).rstrip())
return lines
def preview(grid: list[list[tuple[str, str, str]]], path: Path, cell: tuple[int, int]) -> None:
width, height = cell
image = Image.new("RGB", (len(grid[0]) * width, len(grid) * height), (24, 24, 24))
draw = ImageDraw.Draw(image)
for row, cells in enumerate(grid):
for column, (glyph, foreground, background) in enumerate(cells):
x, y = column * width, row * height
if background:
draw.rectangle([x, y, x + width - 1, y + height - 1], fill=ANSI16[background])
if glyph == " ":
continue
colour = ANSI16.get(foreground, (140, 140, 140))
if glyph == "▀":
box = [x, y, x + width - 1, y + height // 2 - 1]
else:
box = [x, y + height // 2, x + width - 1, y + height - 1]
draw.rectangle(box, fill=colour)
image.resize((image.width * 3, image.height * 3), Image.NEAREST).save(path)
MODULE_TEMPLATE = '''"""Generated header art — do not edit by hand.
{provenance}
Regenerate with::
{command}
Each row is rich markup; ``▀`` is a half block whose foreground is the top
pixel and whose background is the pixel below it. Colours are stock ANSI
palette names, so the art keeps the terminal's own palette.
"""
from __future__ import annotations
HERO: tuple[str, ...] = (
{rows}
)
'''
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("image", nargs="?", type=Path, help="local image file")
parser.add_argument("--url", help="fetch the image from this URL instead")
parser.add_argument("--cols", type=int, default=22, help="terminal columns (default 22)")
parser.add_argument("--rows", type=int, default=7, help="terminal rows (default 7)")
parser.add_argument("--module", type=Path, help="write a Python module with HERO")
parser.add_argument("--preview", type=Path, help="write a PNG preview of the cells")
parser.add_argument("--provenance", default="", help="source note for the module header")
parser.add_argument(
"--key-white",
action="store_true",
help="treat near-white pixels as transparent (logos on white)",
)
args = parser.parse_args(argv)
image = load_image(args.image, args.url, args.key_white)
grid = art_rows(image, args.cols, args.rows)
rows = markup(grid)
if args.preview:
preview(grid, args.preview, (10, 20))
if args.module:
source = args.provenance or args.url or str(args.image)
command = "uv run python tools/ansi_art.py " + " ".join(sys.argv[1:])
body = "\n".join(f" {row!r}," for row in rows)
args.module.write_text(
MODULE_TEMPLATE.format(
provenance=f"Source: {source}",
command=command,
rows=body,
),
encoding="utf-8",
)
print(f"wrote {args.module} ({len(rows)} rows x {args.cols} cols)")
else:
print("\n".join(rows))
return 0
if __name__ == "__main__":
raise SystemExit(main())
Generated
+38 -38
View File
@@ -30,6 +30,44 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
]
[[package]]
name = "beefy-player"
version = "1.2.2"
source = { editable = "." }
dependencies = [
{ name = "dbus-next", marker = "sys_platform == 'linux'" },
{ name = "httpx" },
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
{ name = "numpy", version = "2.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "pillow", version = "12.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "pillow", version = "13.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "rich" },
{ name = "textual" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
[package.metadata]
requires-dist = [
{ name = "dbus-next", marker = "sys_platform == 'linux'", specifier = ">=0.2.3" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "numpy", specifier = ">=2.0" },
{ name = "pillow", specifier = ">=10.0" },
{ name = "rich", specifier = ">=14.2" },
{ name = "textual", specifier = ">=8.0,<9" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.0" },
{ name = "pytest-asyncio", specifier = ">=0.24" },
]
[[package]]
name = "certifi"
version = "2026.10.23"
@@ -682,44 +720,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "beefy-player"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "dbus-next", marker = "sys_platform == 'linux'" },
{ name = "httpx" },
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
{ name = "numpy", version = "2.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "pillow", version = "12.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "pillow", version = "13.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "rich" },
{ name = "textual" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
[package.metadata]
requires-dist = [
{ name = "dbus-next", marker = "sys_platform == 'linux'", specifier = ">=0.2.3" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "numpy", specifier = ">=2.0" },
{ name = "pillow", specifier = ">=10.0" },
{ name = "rich", specifier = ">=14.2" },
{ name = "textual", specifier = ">=8.0,<9" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.0" },
{ name = "pytest-asyncio", specifier = ">=0.24" },
]
[[package]]
name = "textual"
version = "8.2.8"