Compare commits

..
40 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
beefcarver 58e9202e25 test(providers): cover the registry and the app's catalogue independence
Registry lookup and its fallback for a stale slug, SomaFM branding and guessed mirrors, provider round-trip in the config, legacy-config migration and BEEFY_*/SOMAFM_* precedence - plus an end-to-end boot against a fake second provider, which is what actually proves the UI no longer knows about SomaFM.
2026-11-03 16:10:39 -05:00
beefcarver c4cc91ee1a feat(providers): add a pluggable station-catalogue seam
The app now holds one RadioProvider and asks it for stations, song history and stream URLs instead of importing SomaFM's HTTP client directly.

- models.py: provider-neutral Song/Station plus the shared parsers every catalogue needs (Station.provider/.uid keep ids unique once there is more than one).
- providers/base.py: the RadioProvider interface and the toolkit it needs in practice - httpx client factory, .pls/.m3u resolution with an 8h disk cache, and a default stream_candidates() strategy.
- providers/somafm/: everything SomaFM-specific (channels.json, songs/<id>.json, PLS layout, guessed icecast mirrors) behind SomaFMProvider.
- providers/__init__.py: registry, get_provider() and --provider, remembered in the config file.

Widgets render branding, splash copy and the help credits from the active provider, so a second catalogue needs no UI changes; with SomaFM selected the screen is byte-identical to before.
2026-11-03 16:10:30 -05:00
beefcarver e30ca09627 refactor!: rename somafm-tui to beefy-player
The app is now a general internet-radio player rather than a SomaFM client, so the package, console script, MPRIS identity, mpv namespaces, macOS tap names, XDG directories and docs all move to beefy-player.

Environment variables are now BEEFY_* (the old SOMAFM_* spellings are still honoured) and an existing ~/.config/somafm-tui/config.json is migrated on first run, so favourites and volume survive the rename.

The visible interface is untouched: same layout, same logo, same keys.
2026-11-03 16:10:25 -05:00
beefcarver 4bed90b489 docs(visualizer): document the six visualiser modes 2026-11-01 09:58:59 -05:00
beefcarver a42678ef30 test(visualizer): cover the new modes and the full v cycle 2026-11-01 09:58:57 -05:00
beefcarver 6d188c00bc feat(visualizer): add waterfall, radial and braille spectrum modes 2026-11-01 09:58:53 -05:00
beefcarver b9d3d7e809 test(spectrum): cover monitor recovery across tuning 2026-11-01 09:45:47 -05:00
beefcarver ca45303386 fix(spectrum): reset analyser on station change and retry SYNC 2026-11-01 09:45:43 -05:00
beefcarver d2b4f1bf64 fix(ui): keep Bandcamp link across history refreshes 2026-11-01 08:58:46 -05:00
beefcarver 3ae4dc3dab feat(bandcamp): simulate typed autocomplete searches 2026-11-01 08:58:43 -05:00
109 changed files with 10729 additions and 1762 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
```
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright (C) 2026 somafm-tui contributors Copyright (C) 2026 beefy-player contributors
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004 Version 2, December 2004
+198 -282
View File
@@ -1,187 +1,167 @@
# somafm-tui # Beefy Player
![somafm-tui demo](assets/somafm-tui-demo.gif) ![beefy-player demo](assets/beefy-player-demo.gif)
A flashy terminal radio for **[SomaFM](https://somafm.com)** — a live spectrum A flashy terminal player for **internet radio** — a live spectrum analyser,
analyser, song history, and full desktop media integration, drawn with your song history and album art, with desktop media integration on Linux and macOS.
terminal's own ANSI palette on the terminal's default background so it fits It draws with your terminal's own ANSI palette on the terminal's default
any theme (including transparent terminals). background, so it fits any theme, including transparent terminals.
It runs the same way on **Linux** (PulseAudio/PipeWire) and **macOS** Thirteen station catalogues ship today, from SomaFM's curated channels to the
(CoreAudio, iTerm2/Kitty/WezTerm): MPRIS on the desktop bus where that exists, 62,000-station Radio Browser directory, loaded as you scroll and searched live
macOS Now Playing (Control Center and the media keys) where it does not. as you type. Press `p` to switch catalogue without restarting.
No existing SomaFM client is used: the station list, playlists, song history,
audio control, spectrum visualiser and media-session integration are all
implemented here from the public SomaFM endpoints and open protocols.
## Features ## Features
- **Live spectrum analyser** — 72 log-spaced bands with peak caps, a moving - **Live spectrum analyser** — 72 log-spaced bands with peak caps, a palette
gradient taken from your terminal's ANSI palette and a symmetric mirrored gradient and a mirrored spectrum. Six modes (`v`): mirrored bars, plain bars,
spectrum; or a full-height oscilloscope. Three modes (`v`). oscilloscope, scrolling waterfall, radial spectrum and a high-resolution
- **Synced spectrum, not a guess** — by default the analyser records your braille oscilloscope.
actual audio output, so the bars follow what you hear (`SYNC`): the - **Synced to what you hear** — the analyser listens to your actual audio
PulseAudio/PipeWire monitor via `parec` on Linux, and a native Core Audio output by default, so the bars follow the music. If that is unavailable it
process tap on macOS (no driver, no BlackHole, nothing to install). Without falls back to decoding the stream, then to a simulated display.
an output capture it falls back to a muted second `ffmpeg` decode of the - **Now playing and history** — artist, title, album, DJ, listeners, bitrate
stream (`FFT`, can drift), then to a synthesized display (`SIM`). and "played N minutes ago", plus the full recent history. A clickable
- **Real artwork on iTerm2, Kitty and WezTerm** — Kitty uses the kitty **Bandcamp ↗** link appears when the current track can be found there.
graphics protocol; iTerm2 (including on macOS) and WezTerm use their native - **Real album art** — native images in Kitty, iTerm2 and WezTerm; an
iTerm2 inline-image protocol. Images occupy exactly the ON AIR art cell box ANSI-shaded mosaic in every other terminal.
and fall back to the procedural ANSI mosaic for other terminals, failed - **Thirteen station catalogues** — SomaFM (≈50 channels), Radio Paradise,
loads, or blocked graphics passthrough. Images are cached in tilderadio, Bassdrive, dublab, Rinse FM, Refuge Worldwide, LYL Radio, The Lot
`~/.cache/somafm-tui/artwork`. Radio, Cashmere Radio, Datafruits, aNONradio and the Radio Browser directory
- **Now playing** — artist, title, album, DJ, listeners, format/bitrate and (loaded as you scroll in popularity order, with live search across all
"played N minutes ago", with procedural ANSI-shaded album art that changes 62,000).
per track. When the current track can be found on Bandcamp, a clickable - **Favourites, search and schedules** — favourites float to the top and are
**Bandcamp ↗** link appears below it; otherwise the line stays hidden. remembered between runs, including directory stations outside the loaded
- **Last played** — the full recent history from `somafm.com/songs/<id>.json`. page; search by title, genre or description, with live server-side search
- **Every station** — the whole `channels.json` roster (≈50 channels), live across the Radio Browser directory; single-stream stations show their
listener counts, genre search. broadcast schedule instead of a one-item list.
- **Favourites** that float to the top, remembered between runs. - **Desktop media integration** — MPRIS on Linux (GNOME/KDE widgets, lock
- **Desktop media integration** — on Linux an screens, `playerctl` and the media keys) and Now Playing on macOS (Control
`org.mpris.MediaPlayer2.somafmtui.instance<pid>` player exposes Center and the media keys).
`PlaybackStatus`, `Metadata` (title/artist/album/artUrl), `Volume`, and - **Sixteen palettes, theme-aware** — every colour is native ANSI against the
`Play`/`Pause`/`PlayPause`/`Stop`/`Next`/`Previous`, so GNOME/KDE widgets, terminal's default background, so transparent terminals stay transparent and
lock screens, `playerctl` and the media keys pick it up. On macOS the audio the TUI follows your colour scheme. Press `c` to cycle palettes.
engine (mpv) publishes to the system **Now Playing** session, so Control - **Graceful offline** — cached stations and schedules, a bundled fallback
Center, the menu bar and the keyboard media keys drive the app the same way. list, automatic mirror failover and `ffplay` when `mpv` is missing.
- **Theme-aware** — every accent is emitted as native ANSI (not RGB), and the
background is the terminal default (`SGR 49`), so transparent terminals stay
transparent and the TUI adopts your shell/terminal colour scheme.
- **Graceful degradation** — works with cached data offline, falls back to a
bundled station list, reconnects across ICE mirrors automatically, and uses
`ffplay` if `mpv` is missing.
## Requirements ## Installation
`somafm-tui` is a normal Python application: there is nothing to compile and ### Nix
no distro-specific packaging. It runs on any Linux distribution or macOS with
Python **3.10+** and a few command-line audio tools on `$PATH`.
| tool | why | The flake builds the package, runs the test suite and wraps everything the app
| ---- | --- | calls at runtime (`mpv`, `ffmpeg`, and PulseAudio's `parec`/`pactl` — PipeWire
| `python3` | runtime (3.10 or newer; no exact version pin) | speaks the same protocol):
| [`uv`](https://docs.astral.sh/uv/) | creates the venv and installs every Python dependency from `uv.lock` |
| `mpv` | playback and JSON IPC control |
| `ffmpeg` | muted stream decode for the fallback analyser |
| `parec` / `pactl` *(Linux)* | synced monitor capture for the spectrum (`pulseaudio-utils`; works with PipeWire) |
| D-Bus session bus *(Linux)* | MPRIS desktop media integration (`playerctl` is handy for testing) |
On macOS the spectrum capture is part of the app: it uses the Core Audio
process-tap API (macOS 14.2 or newer), so there is no loopback driver to
install.
On macOS the media integration needs nothing extra: mpv is what the system's
Now Playing session (Control Center, media keys) talks to.
Install `uv` once (skip if it is already there):
```bash ```bash
curl -LsSf https://astral.sh/uv/install.sh | sh nix run git+https://tildeforge.dev/beefcarver/beefy-player # try it
nix profile install git+https://tildeforge.dev/beefcarver/beefy-player # install it
``` ```
`brew install uv`, `pipx install uv`, or a distro package also work. If your In a NixOS configuration:
system Python is older than 3.10, let uv manage one: `uv python install 3.12`.
### Linux ```nix
{
inputs.beefy-player.url = "git+https://tildeforge.dev/beefcarver/beefy-player";
Install these with your package manager. Names are usually: outputs = { nixpkgs, beefy-player, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
```bash modules = [
# Debian / Ubuntu beefy-player.nixosModules.default
sudo apt install python3 mpv ffmpeg pulseaudio-utils dbus playerctl {
programs.beefy-player.enable = true;
# Fedora (enable RPM Fusion for ffmpeg if needed) }
sudo dnf install python3 mpv ffmpeg pulseaudio-utils dbus-tools playerctl ];
};
# Arch / Manjaro };
sudo pacman -S python mpv ffmpeg libpulse dbus playerctl }
# openSUSE
sudo zypper install python3 mpv ffmpeg pulseaudio-utils dbus-1-tools playerctl
``` ```
For other distributions, install the equivalents. On a headless machine you Or, for a single user with Home Manager:
can skip the audio/D-Bus tools and use `--no-audio` for a UI-only demo, though
the synced spectrum needs `parec`/`pactl`.
### macOS ```nix
{
inputs.beefy-player.url = "git+https://tildeforge.dev/beefcarver/beefy-player";
```bash outputs = { home-manager, beefy-player, ... }: {
brew install mpv ffmpeg uv # ffplay ships with ffmpeg homeConfigurations.me = home-manager.lib.homeManagerConfiguration {
modules = [
beefy-player.homeModules.default
{
programs.beefy-player.enable = true;
}
];
};
};
}
``` ```
That is enough to run: playback, artwork in iTerm2/Kitty/WezTerm, the synced Both modules add one option, `programs.beefy-player.package`, so a pinned or
Core Audio spectrum tap and the macOS Now Playing integration all work out of overridden build can be substituted for the flake's own. The raw package
the box. (`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.
The first time the analyser starts it asks macOS for **System Audio ### Arch Linux
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 The `aur/PKGBUILD` here is AUR-ready, but the package is not published in the
you point mpv elsewhere with `SOMAFM_MPV_ARGS='--audio-device=...'` the AUR: the AUR has closed new sign-ups, so there is no account to push it to.
analyser falls back to `FFT`. Build and install it from a checkout instead:
Terminal image rendering uses [Pillow](https://python-pillow.org/) and is
implemented directly (Kitty TGP + iTerm2 OSC 1337) — no terminal probing.
## Run
From the repository root, sync the environment and start the app:
```bash ```bash
uv sync # create .venv and install exactly what uv.lock pins git clone https://tildeforge.dev/beefcarver/beefy-player
uv run somafm-tui # run inside that environment cd beefy-player/aur
makepkg -si # needs base-devel; builds, runs the test suite, installs
``` ```
You do **not** need to activate the venv. `uv run` uses `.venv` automatically, Once the AUR accepts new accounts again, the package can be published and
and re-syncs it first if `pyproject.toml` or `uv.lock` changed. If you prefer a `paru -S beefy-player` / `yay -S beefy-player` will work; until then `makepkg`
traditional shell, run `source .venv/bin/activate` and then `somafm-tui`. is the only route. The `aur/PKGBUILD` is tracked and kept in sync with every
release either way.
### Keeping the uv environment in sync The Python side is pulled in automatically. For audio, the output-synced
spectrum and image passthrough through tmux, install the optional tools too:
`uv sync` is the command to remember. It is incremental, safe to re-run, and
should be run after pulling changes, switching branches, or editing
`pyproject.toml`:
```bash ```bash
uv sync # make .venv match uv.lock sudo pacman -S mpv ffmpeg libpulse tmux
uv sync --frozen # strict: fail if uv.lock is out of date, never rewrite it
uv lock --upgrade # refresh uv.lock to the newest allowed versions
uv sync --upgrade # upgrade dependencies and sync .venv in one step
rm -rf .venv && uv sync # last resort if the venv was built by an older Python
``` ```
Useful flags: ### From source
```bash ```bash
uv run somafm-tui --play # start the last station immediately uv sync
uv run somafm-tui --station defcon # pick a station uv run beefy-player
uv run somafm-tui --volume 55 ```
uv run somafm-tui --offline # no network; cached/bundled stations
uv run somafm-tui --no-mpris # no MPRIS / Now Playing, no media keys ## Usage
uv run somafm-tui --list # print station ids
uv run somafm-tui --no-audio # UI demo without mpv/ffmpeg ```bash
uv run somafm-tui --spectrum-source monitor # force synced output capture beefy-player
uv run somafm-tui --spectrum-source stream # force the muted ffmpeg tap
SOMAFM_IMAGES=0 uv run somafm-tui # force the ANSI artwork mosaic
SOMAFM_IMAGES=tgp uv run somafm-tui # force Kitty/TGP artwork
SOMAFM_IMAGES=iterm2 uv run somafm-tui # force iTerm2/WezTerm artwork
``` ```
The UI wants roughly **84×24**; it also works smaller, just less pretty. The UI wants roughly **84×24**; it also works smaller, just less pretty.
## Keys | flag | what it does |
| --- | --- |
| `--provider <slug>` | pick a station catalogue |
| `--station <id>` | pick a station (see `--list`) |
| `--play` | tune the last station right away |
| `--list` | print station ids for the current catalogue |
| `--volume <0-100>` | set the initial volume |
| `--offline` | no network; use cached and bundled stations |
| `--no-audio` | UI demo without `mpv` / `ffmpeg` |
| `--no-mpris` | no MPRIS / Now Playing, no media keys |
| `--spectrum-source monitor\|stream` | force synced output capture or the stream tap |
Environment variables are prefixed `BEEFY_`: `BEEFY_IMAGES` (`tgp`, `iterm2`
or `0`), `BEEFY_MPV_ARGS` and `BEEFY_SPECTRUM_DEVICE`.
### Keys
| key | action | | key | action |
| -------------- | --------------------------------------------- | | --- | --- |
| `↑` / `↓`, `j` / `k` | move through stations | | `↑` / `↓`, `j` / `k` | move through stations |
| `pgup` / `pgdn` | page through stations |
| `enter` | tune in to the selected station | | `enter` | tune in to the selected station |
| `space` | play / pause | | `space` | play / pause |
| `s` | stop | | `s` | stop |
@@ -190,17 +170,41 @@ The UI wants roughly **84×24**; it also works smaller, just less pretty.
| `m` | mute | | `m` | mute |
| `f` | favourite (favourites sort to the top) | | `f` | favourite (favourites sort to the top) |
| `/` | filter by title, genre, description | | `/` | filter by title, genre, description |
| `esc` | clear filter / close search | | `esc` | close the search box; press again to clear the filter |
| `v` | visualiser: mirrored bars → bars → wave | | `v` | visualiser: mirror → bars → wave → waterfall → radial → braille |
| `r` | refresh channels + song history | | `c` | colour palette (16 of them, remembered) |
| `r` | refresh stations, schedule and song history |
| `p` | switch station catalogue |
| `g` / `G` | top / bottom | | `g` / `G` | top / bottom |
| `?` | help | | `?` | help |
| `q` | quit | | `q` | quit |
## Desktop media widgets (MPRIS / Now Playing) ## Station catalogues
**Linux** — while playing, the app exports | catalogue | slug | stations |
`org.mpris.MediaPlayer2.somafmtui.instance<pid>` on the **session bus**. Check it: | --- | --- | --- |
| [SomaFM](https://somafm.com) | `somafm` | ≈50 curated channels |
| [Radio Paradise](https://radioparadise.com) | `radioparadise` | 5 mixes |
| [tilderadio](https://tilderadio.org) | `tilderadio` | 1 + schedule |
| [Bassdrive](https://bassdrive.com) | `bassdrive` | 1 + schedule |
| [dublab](https://dublab.com) | `dublab` | 1 + schedule |
| [Rinse FM](https://rinse.fm) | `rinse` | 3 |
| [Refuge Worldwide](https://refugeworldwide.com) | `refuge` | 1 |
| [LYL Radio](https://lyl.live) | `lyl` | 1 |
| [The Lot Radio](https://thelotradio.com) | `lot` | 1 |
| [Cashmere Radio](https://cashmereradio.com) | `cashmere` | 1 |
| [Datafruits](https://datafruits.fm) | `datafruits` | 1 |
| [aNONradio](https://anonradio.net) | `anonradio` | 1 |
| [Radio Browser](https://www.radio-browser.info) | `radiobrowser` | 62k · paged + live search |
Pick one with `p` in the UI, `--provider <slug>` on the command line, or by
setting `"provider"` in the config file.
## Desktop media integration
**Linux** — while playing, the app exposes an
`org.mpris.MediaPlayer2.beefyplayer.instance<pid>` player on the session bus,
so GNOME/KDE widgets, lock screens, `playerctl` and the media keys pick it up:
```bash ```bash
playerctl -l playerctl -l
@@ -208,152 +212,64 @@ playerctl metadata --format '{{artist}} — {{title}} ({{album}})'
playerctl play-pause playerctl play-pause
``` ```
`mpris:artUrl` is the track's album art when SomaFM provides it, otherwise the If there is no session bus (SSH without `dbus-run-session`, containers, …)
channel logo. If there is no session bus (SSH without `dbus-run-session`, MPRIS is skipped and the status bar shows `mpris off`.
containers, …) MPRIS is skipped and the status bar shows `mpris off`.
**macOS** — mpv owns the system Now Playing session, so Control Center, the **macOS** — mpv publishes to the system Now Playing session, so Control Center,
menu-bar widget and the keyboard media keys (▶︎/⏸, ⏭, ⏮) all target this app the menu bar and the media keys drive the app while it plays. The status bar
while it is playing. mpv publishes the live stream title (the same shows `nowplaying on`. `--no-mpris` (also spelled `--no-media`) turns the media
“Artist - Title” that SomaFM sends with the audio), and the media keys are keys off.
routed back into the TUI, so `⏭`/`⏮` really change station and the panel, the
spectrum and the station list stay in sync. The status bar shows
`nowplaying on`. There is no D-Bus and no extra dependency involved, and
`--no-mpris` (also spelled `--no-media`) turns the media keys off again.
> macOS shows one Now Playing app at a time. If Music/Safari/another mpv is > macOS shows one Now Playing app at a time. If Music, Safari or another mpv is
> playing, the media keys go there instead — quit it or pause it to hand the > playing, the media keys go there instead.
> keys back.
## How it works ## Configuration
- `somafm_tui/api.py` — async `httpx` client for: Settings live in `~/.config/beefy-player/config.json` (favourites, volume,
- `channels.json` → stations, genres, listeners, `.pls` playlist URLs provider and the last station tuned in each catalogue). Cached stations,
- `<station>.pls` → resolved direct icecast URLs (cached 8h) schedules and artwork live in `~/.cache/beefy-player/`.
- `songs/<station>.json` → last played tracks (cached on disk)
Playlist and channel caching means the UI opens instantly and still works
offline.
- `somafm_tui/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.
- `somafm_tui/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.
- `somafm_tui/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.
- `somafm_tui/mediasession.py` — picks the desktop media backend (MPRIS on
Linux, Now Playing on macOS) so the app has one platform-neutral interface.
- `somafm_tui/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).
- `somafm_tui/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.
- `somafm_tui/widgets/` — Textual widgets rendered with native ANSI colours;
backgrounds are `ansi_default` (`SGR 49`), so transparent terminals stay
transparent.
### Files These stay in the usual XDG locations on every platform, macOS included;
`XDG_CONFIG_HOME` and `XDG_CACHE_HOME` are honoured when set.
``` ## Troubleshooting
~/.config/somafm-tui/config.json favourites, volume, last station, mode
~/.cache/somafm-tui/channels.json last good station list
~/.cache/somafm-tui/playlists.json resolved stream URLs
~/.cache/somafm-tui/songs/<id>.json song history snapshots
~/.cache/somafm-tui/artwork/<sha1> cached album/channel artwork
```
These stay in the XDG locations on every platform (macOS included), so the - **Artwork shows the mosaic?** Real images are auto-enabled for Kitty, iTerm2
same dotfiles/config work everywhere; `XDG_CONFIG_HOME`, `XDG_CACHE_HOME` and and WezTerm. Force a protocol with `BEEFY_IMAGES=tgp` (Kitty) or
`XDG_RUNTIME_DIR` are honoured when set. `BEEFY_IMAGES=iterm2` (iTerm2/WezTerm); `BEEFY_IMAGES=0` forces the mosaic.
`--diagnose-images` prints what was detected.
## Notes / troubleshooting - **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
- **Artwork shows the mosaic?** Real images are auto-enabled for Kitty, with `BEEFY_MPV_ARGS`, e.g. `BEEFY_MPV_ARGS='--audio-device=...'`.
iTerm2 and WezTerm. Force a protocol with `SOMAFM_IMAGES=tgp` (Kitty/TGP) or - **Spectrum says `FFT` instead of `SYNC`?** Output capture is unavailable or
`SOMAFM_IMAGES=iterm2` (iTerm2/WezTerm); `SOMAFM_IMAGES=0` forces the ANSI failed, so the analyser is decoding the stream instead; the bars are still
mosaic. iTerm2 on macOS is detected from `TERM_PROGRAM`/`LC_TERMINAL`, and real audio but can drift. Force sync with `--spectrum-source monitor`, or
empty or `0` values in `TMUX`/`ZELLIJ`/`STY` (exported by some wrappers) are pick a specific monitor with `BEEFY_SPECTRUM_DEVICE` on Linux. `SIM` means the
ignored so they cannot downgrade it. Kitty inside tmux is attempted through bars are synthesized.
tmux passthrough (enable `set -g allow-passthrough on`); iTerm2/WezTerm
inside tmux default to the mosaic. Images are cached under
`~/.cache/somafm-tui/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 somafm-tui` 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?** `somafm-tui` needs `mpv` (preferred) or `ffplay`. Without
either, the UI still runs and tells you. Extra mpv args can be injected with
`SOMAFM_MPV_ARGS` (e.g. `SOMAFM_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 `SOMAFM_SPECTRUM_DEVICE=<source>` on Linux to pick
a specific monitor. `SIM` means the bars are synthesized.
- **Diagnose artwork:** `uv run somafm-tui --diagnose-images` prints the
detected protocol, widget class, terminal env, dependency versions and cache
path. If `protocol` is `None`, force it with `SOMAFM_IMAGES=tgp` (Kitty) or
`SOMAFM_IMAGES=iterm2` (iTerm2/WezTerm).
- **`SYNC` but the bars are flat?** mpv may be playing to a different sink (or
directly to ALSA). Point `SOMAFM_SPECTRUM_DEVICE` at that sink's monitor, or
use `--spectrum-source stream`.
- **macOS: spectrum stays `FFT`?** Grant your terminal **System Audio - **macOS: spectrum stays `FFT`?** Grant your terminal **System Audio
Recording** permission (*System Settings → Privacy & Security → System Audio Recording** permission under *System Settings → Privacy & Security → System
Recording*), then restart the app; the status bar explains when the tap is Audio Recording*, then restart the app. Also check that mpv is playing to the
silent because of this. Also check that mpv is playing to the default output default output device, since that is what the analyser records.
device, since that is what the tap records. - **macOS: media keys do nothing?** Another app owns the Now Playing session,
- **macOS: media keys do nothing?** Another app currently owns the Now Playing the app was started with `--no-mpris`, or audio fell back to `ffplay` (mpv is
session, or the app was started with `--no-mpris`, or the audio backend fell required for Now Playing).
back to `ffplay` (mpv is required for Now Playing). The status bar shows - **MPRIS says off?** There is no session bus. Run from your desktop session,
`nowplaying on` when the integration is armed. or use `dbus-run-session` for a private bus. macOS does not use MPRIS.
- **macOS: Control Center shows only a title?** mpv publishes the stream's live
“Artist - Title” as the Now Playing title; artist/album fields and artwork are
not filled in by macOS for plain audio streams. The TUI itself always shows
the full metadata and album art.
- **Bandwidth** is unchanged in SYNC mode (the monitor capture creates no
second stream). The FFT fallback opens one extra stream; if you are on a
metered link, use `--spectrum-source monitor` (or `--no-audio` for a UI-only
demo).
- SomaFM is listener-supported: if you enjoy it, [donate](https://somafm.com/support/).
## NixOS (optional aside) ## Development
None of the above needs Nix. If you *are* on NixOS, `shell.nix` wraps the same
`uv sync` / `uv run` workflow in an FHS environment, because uv installs
manylinux wheels such as NumPy that expect `libstdc++.so.6` in the usual
`/usr/lib` paths:
```bash ```bash
nix-shell # enter the FHS dev shell uv sync # create the environment
uv sync uv run pytest # test suite
uv run somafm-tui uvx ruff check . # lint
``` ```
- If you see `libstdc++.so.6: cannot open shared object file`, make sure you Each catalogue is a self-contained provider module under
are inside `nix-shell`, then recreate the venv once with `beefy_player/providers/`; `base.py` documents the interface. `tools/ansi_art.py`
`rm -rf .venv && uv sync`. converts a PNG logo into half-block ANSI art. On NixOS, `nix develop` (or the
- `nix-shell --run "..."` does not work with FHS `shell.nix` files; enter the older `shell.nix`) provides the same `uv sync` / `uv run` workflow.
shell interactively and run commands there.
- FHS shells need unprivileged user namespaces. `bwrap: No permissions to
create a new namespace` means they are disabled (for example in a locked-down
container); run the app on the host instead.
## Licence ## Licence
WTFPL — Do What The Fuck You Want To Public License, Version 2. See WTFPL — Do What The Fuck You Want To Public License, Version 2.
[LICENSE](LICENSE). SomaFM and its channel names are trademarks of See [LICENSE](LICENSE).
SomaFM.com, LLC.
Terminal image rendering uses [Pillow](https://python-pillow.org/).
File renamed without changes.
+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.
+3
View File
@@ -0,0 +1,3 @@
"""Beefy Player — a flashy terminal player for internet radio."""
__version__ = "1.2.2"
@@ -6,16 +6,23 @@ import argparse
import asyncio import asyncio
from collections.abc import Sequence from collections.abc import Sequence
from . import __version__, api from . import __version__
from .fallback import fallback_channels from .providers import available_providers, get_provider
from .providers.base import make_client
from .spectrum import SOURCE_MODES from .spectrum import SOURCE_MODES
def _build_parser() -> argparse.ArgumentParser: def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="somafm-tui", prog="beefy-player",
description="A flashy terminal radio for somafm.com — live spectrum, " description="Beefy Player — a flashy terminal player for internet "
"song history and desktop media integration.", "radio: live spectrum, song history and desktop media integration.",
)
parser.add_argument(
"--provider",
choices=available_providers(),
metavar="ID",
help="station catalogue to use (default: the one in the config file)",
) )
parser.add_argument("--station", metavar="ID", help="station id to select") parser.add_argument("--station", metavar="ID", help="station id to select")
parser.add_argument( parser.add_argument(
@@ -72,13 +79,15 @@ def _build_parser() -> argparse.ArgumentParser:
return parser return parser
def _list_stations(offline: bool) -> int: def _list_stations(offline: bool, provider_id: str | None = None) -> int:
stations = api.load_cached_channels() provider = get_provider(provider_id)
stations = provider.load_cached_stations()
if not stations and not offline: if not stations and not offline:
async def load(): async def load():
client = api.make_client() client = make_client(provider.user_agent)
try: try:
return await api.fetch_channels(client) return await provider.fetch_stations(client)
finally: finally:
await client.aclose() await client.aclose()
@@ -87,7 +96,8 @@ def _list_stations(offline: bool) -> int:
except Exception: except Exception:
stations = [] stations = []
if not stations: if not stations:
stations = api.parse_channels({"channels": fallback_channels()}) stations = provider.fallback_stations()
print(f"# {provider.name} ({provider.id}) — {provider.station_source}")
for station in sorted(stations, key=lambda item: item.title.casefold()): for station in sorted(stations, key=lambda item: item.title.casefold()):
print(f"{station.id:<20} {station.title}") print(f"{station.id:<20} {station.title}")
return 0 return 0
@@ -103,13 +113,14 @@ def main(argv: Sequence[str] | None = None) -> int:
return 0 return 0
if args.list: if args.list:
return _list_stations(args.offline) return _list_stations(args.offline, args.provider)
# Import the app lazily so --list/--version do not probe the terminal for # Import the app lazily so --list/--version do not probe the terminal for
# image protocols. # image protocols.
from .app import SomaFMApp from .app import BeefyPlayerApp
app = SomaFMApp( app = BeefyPlayerApp(
provider=args.provider,
autoplay=args.autoplay, autoplay=args.autoplay,
station_id=args.station, station_id=args.station,
volume=args.volume, volume=args.volume,
File diff suppressed because it is too large. Load diff
@@ -1,16 +1,16 @@
"""Terminal artwork support. """Terminal artwork support.
The TUI draws a procedural ANSI mosaic when the terminal cannot display real The TUI draws a procedural ANSI mosaic when the terminal cannot display real
images. On terminals with a graphics protocol we fetch the SomaFM album art images. On terminals with a graphics protocol we fetch the station's album
(or channel logo) and render it directly, with the mosaic kept behind it: art (or channel logo) and render it directly, with the mosaic behind it:
* Kitty -> Terminal Graphics Protocol (direct image placement) * Kitty -> Terminal Graphics Protocol (direct image placement)
* iTerm2 / WezTerm -> native iTerm2 inline images (OSC 1337) * iTerm2 / WezTerm -> native iTerm2 inline images (OSC 1337)
The protocols are implemented directly; there is no terminal capability probe The protocols are implemented directly; there is no terminal capability probe
and no extra terminal input, so nothing leaks into the shell at exit. and no extra terminal input, so nothing leaks into the shell at exit.
Set ``SOMAFM_IMAGES=0`` to force the ANSI fallback, or ``SOMAFM_IMAGES=tgp`` / Set ``BEEFY_IMAGES=0`` to force the ANSI fallback, or ``BEEFY_IMAGES=tgp`` /
``SOMAFM_IMAGES=iterm2`` to force a protocol. ``BEEFY_IMAGES=iterm2`` to force a protocol.
""" """
from __future__ import annotations from __future__ import annotations
@@ -47,7 +47,9 @@ def detect_protocol(environ: Mapping[str, str] | None = None) -> str | None:
env = os.environ if environ is None else environ env = os.environ if environ is None else environ
if "NO_COLOR" in env: if "NO_COLOR" in env:
return None return None
override = (env.get("SOMAFM_IMAGES") or "").strip().lower() override = (
env.get("BEEFY_IMAGES") or env.get("SOMAFM_IMAGES") or ""
).strip().lower()
if override in _OFF_VALUES: if override in _OFF_VALUES:
return None return None
if override in _PROTOCOL_VALUES: if override in _PROTOCOL_VALUES:
@@ -154,7 +156,7 @@ def cell_size() -> tuple[int, int]:
def _cache_path(url: str): def _cache_path(url: str):
digest = hashlib.sha1(url.encode("utf-8")).hexdigest() digest = hashlib.sha1(url.encode("utf-8")).hexdigest()
return config.CACHE_DIR / "artwork" / digest return config.ARTWORK_CACHE_DIR / digest
def load_cached(url: str) -> bytes | None: def load_cached(url: str) -> bytes | None:
@@ -197,13 +199,13 @@ def diagnostics() -> dict[str, object]:
"ITERM_SESSION_ID": env.get("ITERM_SESSION_ID", ""), "ITERM_SESSION_ID": env.get("ITERM_SESSION_ID", ""),
"TMUX": env.get("TMUX", ""), "TMUX": env.get("TMUX", ""),
"ZELLIJ": env.get("ZELLIJ", ""), "ZELLIJ": env.get("ZELLIJ", ""),
"SOMAFM_IMAGES": env.get("SOMAFM_IMAGES", ""), "BEEFY_IMAGES": env.get("BEEFY_IMAGES") or env.get("SOMAFM_IMAGES", ""),
"NO_COLOR": "set" if "NO_COLOR" in env else "", "NO_COLOR": "set" if "NO_COLOR" in env else "",
"protocol": PROTOCOL, "protocol": PROTOCOL,
"supported": supported(), "supported": supported(),
"cell_size": cell_size(), "cell_size": cell_size(),
"pillow": pkg_version("pillow"), "pillow": pkg_version("pillow"),
"cache": str(config.CACHE_DIR / "artwork"), "cache": str(config.ARTWORK_CACHE_DIR),
} }
@@ -228,22 +230,38 @@ async def fetch(client: httpx.AsyncClient, url: str) -> bytes | None:
def fit_image(data: bytes, width: int, height: int): 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: try:
from PIL import Image, ImageOps from PIL import Image, ImageOps
with Image.open(BytesIO(data)) as source: 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") image = source.convert("RGBA")
cell_width, cell_height = cell_size() cell_width, cell_height = cell_size()
target = ( target = (
max(1, width * cell_width), max(1, width * cell_width),
max(1, height * cell_height), max(1, height * cell_height),
) )
return ImageOps.fit( fitted = ImageOps.contain(image, target, method=Image.Resampling.LANCZOS)
image, background = (0, 0, 0, 255) if has_alpha else (0, 0, 0, 0)
target, canvas = Image.new("RGBA", target, background)
method=Image.Resampling.LANCZOS, canvas.alpha_composite(
centering=(0.5, 0.5), fitted,
(
(target[0] - fitted.width) // 2,
(target[1] - fitted.height) // 2,
),
) )
return canvas
except Exception: except Exception:
return None return None
@@ -1,8 +1,11 @@
"""Look up the now-playing track on Bandcamp. """Look up the now-playing track on Bandcamp.
Bandcamp's own search box talks to an unauthenticated JSON endpoint Bandcamp's own search box talks to an unauthenticated JSON endpoint
(``/api/bcsearch_public_api/1/autocomplete_elastic``); this uses that instead (``/api/bcsearch_public_api/1/autocomplete_elastic``). The endpoint is meant
of scraping the search page. It first searches for the artist and track, to be driven by a person typing, so this module mimics that: the query is
normalised, then sent as a series of growing chunks (three to five characters
at a time). After every chunk the returned results are matched fuzzily
against the artist and track. It first searches for the artist and track,
then falls back to the artist and album and reads the track link from that then falls back to the artist and album and reads the track link from that
album page. Anything unexpected — a bot challenge, a layout change, an empty album page. Anything unexpected — a bot challenge, a layout change, an empty
result set — is treated as "no link" rather than as an error. result set — is treated as "no link" rather than as an error.
@@ -10,9 +13,9 @@ result set — is treated as "no link" rather than as an error.
from __future__ import annotations from __future__ import annotations
import difflib
import html import html
import json import json
import re
import unicodedata import unicodedata
from dataclasses import dataclass from dataclasses import dataclass
from html.parser import HTMLParser from html.parser import HTMLParser
@@ -51,6 +54,27 @@ def _clean_text(value: str) -> str:
return " ".join(html.unescape(value or "").split()) return " ".join(html.unescape(value or "").split())
def _clean_query_text(value: str) -> str:
"""Normalise text for the autocomplete endpoint.
Em dashes, brackets, parentheses and every other punctuation mark except
comma, period, exclamation mark and question mark are removed. The
result is lower-cased and whitespace is collapsed. Em dashes become a
space so that ``Artist—Track`` does not turn into one word.
"""
decomposed = unicodedata.normalize("NFKD", html.unescape(value or ""))
text = "".join(
character for character in decomposed if not unicodedata.combining(character)
).casefold()
cleaned: list[str] = []
for character in text:
if character.isalnum() or character in ",.!?":
cleaned.append(character)
elif character.isspace() or character in "—–―":
cleaned.append(" ")
return " ".join("".join(cleaned).split())
def _clean_url(value: str, base: str = "https://bandcamp.com/") -> str: def _clean_url(value: str, base: str = "https://bandcamp.com/") -> str:
text = html.unescape(value or "").strip() text = html.unescape(value or "").strip()
if not text: if not text:
@@ -226,15 +250,43 @@ def parse_album_tracks(markup: str, album_url: str) -> list[SearchResult]:
def build_query(*values: str) -> str: def build_query(*values: str) -> str:
terms: list[str] = [] """Join values into the lower-case query string sent while typing."""
seen: set[str] = set() terms = [_clean_query_text(value) for value in values]
for value in values: return " ".join(term for term in terms if term)
text = _clean_text(value)
folded = text.casefold()
if text and folded not in seen: MIN_TYPING_GROUP = 3
seen.add(folded) MAX_TYPING_GROUP = 5
terms.append(text)
return " ".join(terms)
def build_prefixes(query: str) -> list[str]:
"""Return the growing strings produced by "typing" *query*.
Each group is between :data:`MIN_TYPING_GROUP` and
:data:`MAX_TYPING_GROUP` characters long, and every prefix contains all
of the text typed so far. For example ``diskette park dreams`` produces
``dis``, ``disket``, ``diskette ``, ``diskette par``, …
"""
text = query.strip()
if not text:
return []
groups: list[str] = []
index = 0
while index < len(text):
remaining = len(text) - index
if remaining <= MAX_TYPING_GROUP:
groups.append(text[index:])
break
groups.append(text[index : index + MIN_TYPING_GROUP])
index += MIN_TYPING_GROUP
prefixes: list[str] = []
typed = ""
for group in groups:
typed += group
prefixes.append(typed)
return prefixes
def _normalise(value: str) -> str: def _normalise(value: str) -> str:
@@ -242,8 +294,10 @@ def _normalise(value: str) -> str:
ascii_text = "".join( ascii_text = "".join(
character for character in decomposed if not unicodedata.combining(character) character for character in decomposed if not unicodedata.combining(character)
) )
ascii_text = ascii_text.replace("&", " and ") ascii_text = ascii_text.replace("&", " and ").casefold()
return " ".join(re.findall(r"[a-z0-9]+", ascii_text.casefold())) return " ".join(
"".join(character if character.isalnum() else " " for character in ascii_text).split()
)
def _tokens(value: str) -> set[str]: def _tokens(value: str) -> set[str]:
@@ -251,16 +305,27 @@ def _tokens(value: str) -> set[str]:
def _matches(expected: str, actual: str) -> bool: def _matches(expected: str, actual: str) -> bool:
expected_tokens = _tokens(expected) """Case- and whitespace-insensitive fuzzy comparison of two labels."""
if not expected_tokens: expected_key = _normalise(expected)
if not expected_key:
return True return True
actual_tokens = _tokens(actual) actual_key = _normalise(actual)
if not actual_tokens: if not actual_key:
return False return False
if expected_key == actual_key:
return True
expected_tokens = set(expected_key.split())
actual_tokens = set(actual_key.split())
if expected_tokens <= actual_tokens or actual_tokens <= expected_tokens: if expected_tokens <= actual_tokens or actual_tokens <= expected_tokens:
return True return True
overlap = len(expected_tokens & actual_tokens) overlap = len(expected_tokens & actual_tokens)
return overlap / len(expected_tokens) >= 0.6 if overlap / len(expected_tokens) >= 0.6:
return True
# Tolerate small spelling differences and typos once the word-level
# comparison has failed.
return difflib.SequenceMatcher(None, expected_key, actual_key).ratio() >= 0.85
def pick_track( def pick_track(
@@ -336,13 +401,28 @@ async def _album_tracks(
return parse_album_tracks(response.text, album_url) return parse_album_tracks(response.text, album_url)
async def _search_while_typing(
client: httpx.AsyncClient,
query: str,
search_filter: str,
pick,
) -> str | None:
"""Send growing prefixes of *query* and return the first selected URL."""
for prefix in build_prefixes(query):
url = pick(await _search(client, prefix, search_filter))
if url:
return url
return None
async def find_track( async def find_track(
client: httpx.AsyncClient, artist: str, album: str, title: str client: httpx.AsyncClient, artist: str, album: str, title: str
) -> str | None: ) -> str | None:
"""Find the now-playing track on Bandcamp. """Find the now-playing track on Bandcamp.
Search for the artist + track first. If that finds nothing, search for Type the artist + track query into the autocomplete endpoint first. If
the artist + album, open the album page and look for the track there. that finds nothing, do the same for the artist + album, open the album
page and look for the track there.
""" """
artist = _clean_text(artist) artist = _clean_text(artist)
album = _clean_text(album) album = _clean_text(album)
@@ -351,8 +431,11 @@ async def find_track(
return None return None
track_query = build_query(artist, title) track_query = build_query(artist, title)
url = pick_track( url = await _search_while_typing(
await _search(client, track_query, "t"), artist, "", title client,
track_query,
"t",
lambda results: pick_track(results, artist, "", title),
) )
if url: if url:
return url return url
@@ -361,7 +444,12 @@ async def find_track(
return None return None
album_query = build_query(artist, album) album_query = build_query(artist, album)
album_url = pick_album(await _search(client, album_query, "a"), artist, album) album_url = await _search_while_typing(
client,
album_query,
"a",
lambda results: pick_album(results, artist, album),
)
if not album_url: if not album_url:
return None return None
+225
View File
@@ -0,0 +1,225 @@
"""Paths, persistent settings and tiny cache helpers.
Everything lives in the usual XDG places:
~/.config/beefy-player/config.json favourites, volume, station
~/.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
Settings from the pre-rename ``somafm-tui`` directory are copied over once, so
upgrading does not lose favourites or volume.
"""
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"
#: Name the app used before it became Beefy Player; only read, once, to migrate.
LEGACY_APP_NAME = "somafm-tui"
#: What we send as ``User-Agent`` to catalogues, icecast mirrors and mpv.
USER_AGENT = f"{APP_NAME}/{__version__} (terminal radio)"
#: Environment variables are namespaced ``BEEFY_*``; the old ``SOMAFM_*`` names
#: are still honoured so existing shell profiles keep working.
ENV_PREFIX = "BEEFY_"
LEGACY_ENV_PREFIX = "SOMAFM_"
def _xdg(env_var: str, fallback: str) -> Path:
value = os.environ.get(env_var)
if value:
return Path(value).expanduser()
return Path.home() / fallback
def env_var(suffix: str) -> str:
"""``BEEFY_<suffix>``, falling back to the legacy ``SOMAFM_<suffix>``."""
value = os.environ.get(ENV_PREFIX + suffix)
if value is None:
value = os.environ.get(LEGACY_ENV_PREFIX + suffix)
return (value or "").strip()
CONFIG_DIR = _xdg("XDG_CONFIG_HOME", ".config") / APP_NAME
CACHE_DIR = _xdg("XDG_CACHE_HOME", ".cache") / APP_NAME
RUNTIME_DIR = Path(os.environ.get("XDG_RUNTIME_DIR") or "/tmp") / APP_NAME
CONFIG_PATH = CONFIG_DIR / "config.json"
CHANNELS_CACHE = CACHE_DIR / "channels.json"
PLAYLIST_CACHE = CACHE_DIR / "playlists.json"
SONGS_CACHE_DIR = CACHE_DIR / "songs"
ARTWORK_CACHE_DIR = CACHE_DIR / "artwork"
#: Pre-rename location, read once by :meth:`Config.load`.
LEGACY_CONFIG_PATH = _xdg("XDG_CONFIG_HOME", ".config") / LEGACY_APP_NAME / "config.json"
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):
with contextlib.suppress(OSError):
directory.mkdir(parents=True, exist_ok=True)
def atomic_write_text(path: Path, text: str) -> None:
"""Write ``text`` to ``path`` without ever leaving a half-written file."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(f".{path.name}.tmp")
tmp.write_text(text, encoding="utf-8")
os.replace(tmp, path)
def read_json(path: Path) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
def write_json(path: Path, payload: Any) -> None:
with contextlib.suppress(OSError):
atomic_write_text(path, json.dumps(payload, indent=2, ensure_ascii=False))
def migrate_legacy_state() -> None:
"""Copy a pre-rename config file into place, once.
Favourites, volume and the last station are the only state worth keeping;
caches are rebuilt on the next run.
"""
try:
if CONFIG_PATH.exists() or not LEGACY_CONFIG_PATH.is_file():
return
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
shutil.copyfile(LEGACY_CONFIG_PATH, CONFIG_PATH)
except OSError:
pass
@dataclass(slots=True)
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"
#: Publish what is playing to the desktop media session (MPRIS on Linux,
#: macOS Now Playing via mpv) and honour the media keys. The JSON key is
#: spelled ``mpris`` for backwards compatibility.
mpris: bool = True
@classmethod
def load(cls) -> Config:
migrate_legacy_state()
raw = read_json(CONFIG_PATH)
config = cls()
if not isinstance(raw, dict):
return config
favorites = raw.get("favorites")
if isinstance(favorites, list):
config.favorites = [str(item) for item in favorites if isinstance(item, str)]
last_station = raw.get("last_station")
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)))
visualizer = raw.get("visualizer")
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()
if isinstance(raw.get("autoplay"), bool):
config.autoplay = raw["autoplay"]
if isinstance(raw.get("mpris"), bool):
config.mpris = raw["mpris"]
return config
def save(self) -> None:
try:
ensure_dirs()
atomic_write_text(
CONFIG_PATH, json.dumps(asdict(self), indent=2, ensure_ascii=False)
)
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
def toggle_favorite(self, station_id: str) -> bool:
if station_id in self.favorites:
self.favorites.remove(station_id)
return False
self.favorites.append(station_id)
return True
@@ -407,7 +407,7 @@ class SystemAudioTap:
if not nsuuid: if not nsuuid:
raise TapError("could not create the tap UUID") raise TapError("could not create the tap UUID")
_send_object(description, b"setUUID:", nsuuid) _send_object(description, b"setUUID:", nsuuid)
_send_object(description, b"setName:", cfstr("somafm-tui")) _send_object(description, b"setName:", cfstr("beefy-player"))
_send_flag(description, b"setPrivate:", True) _send_flag(description, b"setPrivate:", True)
# 0 == CATapUnmuted: the audio we record keeps playing as usual. # 0 == CATapUnmuted: the audio we record keeps playing as usual.
_send_integer(description, b"setMuteBehavior:", 0) _send_integer(description, b"setMuteBehavior:", 0)
@@ -460,7 +460,7 @@ class SystemAudioTap:
owned.append(aggregate) owned.append(aggregate)
for key, value in ( for key, value in (
(_AGGREGATE_UID, cfstr(str(uuid.uuid4()).upper())), (_AGGREGATE_UID, cfstr(str(uuid.uuid4()).upper())),
(_AGGREGATE_NAME, cfstr("somafm-tui tap")), (_AGGREGATE_NAME, cfstr("beefy-player tap")),
(_AGGREGATE_PRIVATE, _boolean(True)), (_AGGREGATE_PRIVATE, _boolean(True)),
(_AGGREGATE_STACKED, _boolean(False)), (_AGGREGATE_STACKED, _boolean(False)),
(_AGGREGATE_TAPS, tap_list), (_AGGREGATE_TAPS, tap_list),
@@ -10,15 +10,15 @@ The rest of the app only ever talks to this small surface::
which keeps the platform differences in one place: which keeps the platform differences in one place:
* **Linux / BSD / any D-Bus desktop** — :class:`somafm_tui.mpris.MprisService` * **Linux / BSD / any D-Bus desktop** — :class:`beefy_player.mpris.MprisService`
exports ``org.mpris.MediaPlayer2.somafmtui.instance<pid>`` on the session exports ``org.mpris.MediaPlayer2.beefyplayer.instance<pid>`` on the session
bus, so GNOME/KDE widgets, lock screens and ``playerctl`` show the station bus, so GNOME/KDE widgets, lock screens and ``playerctl`` show the station
and can drive playback. and can drive playback.
* **macOS** — mpv *is* the app the system knows about: its Cocoa backend * **macOS** — mpv *is* the app the system knows about: its Cocoa backend
registers with ``MPRemoteCommandCenter``/``MPNowPlayingInfoCenter``, which registers with ``MPRemoteCommandCenter``/``MPNowPlayingInfoCenter``, which
is what Control Center, the menu bar and the media keys talk to. So there is what Control Center, the menu bar and the media keys talk to. So there
is nothing to publish here, and the media keys are routed back into the TUI is nothing to publish here, and the media keys are routed back into the TUI
by mpv (see :mod:`somafm_tui.player`). :class:`NowPlayingSession` only by mpv (see :mod:`beefy_player.player`). :class:`NowPlayingSession` only
keeps the state the status bar renders and stays out of the way. keeps the state the status bar renders and stays out of the way.
Anything else (or a disabled/absent backend) degrades to an inert session Anything else (or a disabled/absent backend) degrades to an inert session
@@ -114,7 +114,7 @@ class NowPlayingSession(MediaSession):
"""macOS: mpv owns the system Now Playing session, we just mirror state. """macOS: mpv owns the system Now Playing session, we just mirror state.
mpv's Cocoa backend publishes the live ICY title (and play/pause state) to mpv's Cocoa backend publishes the live ICY title (and play/pause state) to
Control Center and receives the media keys; :mod:`somafm_tui.player` routes Control Center and receives the media keys; :mod:`beefy_player.player` routes
those key presses back into this app. ``available`` is false when the audio those key presses back into this app. ``available`` is false when the audio
backend is not mpv, because then nobody publishes anything. backend is not mpv, because then nobody publishes anything.
""" """
+210
View File
@@ -0,0 +1,210 @@
"""Provider-neutral data types.
Every radio catalogue — SomaFM today, anything else later — is reduced to
:class:`Station` and :class:`Song` before the UI sees it, so the widgets and
the app never have to know which catalogue a station came from. The parsers
at the bottom are the small, boring helpers (numbers, dates, "Artist - Title"
strings) that every provider ends up needing.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
_QUALITY_RANK = {"highest": 0, "high": 1, "low": 2}
_FORMAT_RANK = {"mp3": 0, "aac": 1, "aacp": 2}
_FORMAT_LABEL = {"aacp": "aac+", "aac": "aac", "mp3": "mp3"}
@dataclass(slots=True)
class Song:
title: str = ""
artist: str = ""
album: str = ""
art_url: str = ""
bandcamp_url: str = ""
played_at: datetime | None = None
@property
def display_title(self) -> str:
return self.title.strip() or "Unknown track"
@property
def display_artist(self) -> str:
return self.artist.strip() or "Unknown artist"
def key(self) -> str:
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
title: str
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 = ""
playlists: list[dict[str, str]] = field(default_factory=list)
now_playing: Song | None = None
history: list[Song] = field(default_factory=list)
favorite: bool = False
#: Id of the provider that produced this station (see
#: :mod:`beefy_player.providers`). Station ids are only unique within a
#: catalogue, so anything persisted across providers should use
#: :attr:`uid` instead.
provider: str = ""
@property
def uid(self) -> str:
"""Provider-qualified id, stable once more than one catalogue exists."""
return f"{self.provider}:{self.id}" if self.provider else self.id
@property
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,
key=lambda pl: (
_QUALITY_RANK.get(pl.get("quality", ""), 3),
_FORMAT_RANK.get(pl.get("format", ""), 3),
),
)
@property
def format_label(self) -> str:
playlists = self.sorted_playlists()
if not playlists:
return "stream"
first = playlists[0]
fmt = _FORMAT_LABEL.get(
first.get("format", ""), first.get("format") or "stream"
)
return f"{fmt} · {playlist_bitrate(first)}k"
# --------------------------------------------------------------------------- #
# small, provider-agnostic parsers
# --------------------------------------------------------------------------- #
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
)
if not match:
return 128
value = int(match.group(1))
return 128 if value == 130 else value
def to_int(value: Any, default: int = 0) -> int:
"""Forgiving ``int`` — catalogues like to send ``"1,873"`` as text."""
if isinstance(value, bool):
return default
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
if isinstance(value, str):
digits = re.sub(r"[^0-9]", "", value)
if digits:
try:
return int(digits)
except ValueError:
return default
return default
def parse_date(value: Any) -> datetime | None:
"""Unix timestamp, ISO 8601 or one of the usual ``%Y-%m-%d`` shapes."""
if value is None or value == "":
return None
if isinstance(value, (int, float)):
try:
return datetime.fromtimestamp(float(value), tz=timezone.utc)
except (OverflowError, OSError, ValueError):
return None
if isinstance(value, str):
text = value.strip()
if text.isdigit():
try:
return datetime.fromtimestamp(int(text), tz=timezone.utc)
except (OverflowError, OSError, ValueError):
return None
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
parsed = None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y/%m/%d %H:%M:%S"):
try:
parsed = datetime.strptime(text, fmt)
break
except ValueError:
continue
if parsed is not None:
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
return None
_SEPARATORS = (" - ", " – ", " — ", " -- ")
def parse_last_playing(text: str | None) -> Song | None:
"""Split the ``"Artist - Title"`` blurb most catalogues send."""
if not isinstance(text, str):
return None
cleaned = text.strip()
if not cleaned:
return None
for separator in _SEPARATORS:
if separator in cleaned:
artist, title = cleaned.split(separator, 1)
return Song(title=title.strip(), artist=artist.strip())
return Song(title=cleaned)
+11 -9
View File
@@ -104,10 +104,12 @@ def build_metadata(
(track_key or f"{station}|{artist}|{title}").encode("utf-8") (track_key or f"{station}|{artist}|{title}").encode("utf-8")
).hexdigest()[:24] ).hexdigest()[:24]
metadata: dict[str, Any] = { metadata: dict[str, Any] = {
"mpris:trackid": Variant("o", f"/org/somafm/track/{digest}"), "mpris:trackid": Variant("o", f"/org/beefyplayer/track/{digest}"),
"xesam:title": Variant("s", title or station or "SomaFM"), "xesam:title": Variant("s", title or station or "Beefy Player"),
"xesam:artist": Variant("as", [artist] if artist else ["SomaFM"]), "xesam:artist": Variant("as", [artist] if artist else ["Beefy Player"]),
"xesam:comment": Variant("as", [f"SomaFM · {station}"] if station else ["SomaFM"]), "xesam:comment": Variant(
"as", [f"Beefy Player · {station}"] if station else ["Beefy Player"]
),
} }
if album: if album:
metadata["xesam:album"] = Variant("s", album) metadata["xesam:album"] = Variant("s", album)
@@ -125,11 +127,11 @@ class _RootInterface(ServiceInterface):
@dbus_property(name="Identity", access=PropertyAccess.READ) @dbus_property(name="Identity", access=PropertyAccess.READ)
def Identity(self) -> "s": def Identity(self) -> "s":
return "SomaFM TUI" return "Beefy Player"
@dbus_property(name="DesktopEntry", access=PropertyAccess.READ) @dbus_property(name="DesktopEntry", access=PropertyAccess.READ)
def DesktopEntry(self) -> "s": def DesktopEntry(self) -> "s":
return "somafm-tui" return "beefy-player"
@dbus_property(name="CanQuit", access=PropertyAccess.READ) @dbus_property(name="CanQuit", access=PropertyAccess.READ)
def CanQuit(self) -> "b": def CanQuit(self) -> "b":
@@ -284,7 +286,7 @@ class _PlayerInterface(ServiceInterface):
class MprisService: class MprisService:
#: Status-bar name for this backend (see :mod:`somafm_tui.mediasession`). #: Status-bar name for this backend (see :mod:`beefy_player.mediasession`).
label = "mpris" label = "mpris"
def __init__( def __init__(
@@ -306,7 +308,7 @@ class MprisService:
self._status = "Stopped" self._status = "Stopped"
self._metadata: dict[str, Any] = {} self._metadata: dict[str, Any] = {}
self.bus_name = ( self.bus_name = (
f"org.mpris.MediaPlayer2.somafmtui.instance{os.getpid()}" f"org.mpris.MediaPlayer2.beefyplayer.instance{os.getpid()}"
) )
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -317,7 +319,7 @@ class MprisService:
if not self.enabled: if not self.enabled:
return return
self._thread = threading.Thread( self._thread = threading.Thread(
target=self._thread_main, name="somafm-mpris", daemon=True target=self._thread_main, name="beefy-player-mpris", daemon=True
) )
self._thread.start() self._thread.start()
+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)
+13 -14
View File
@@ -28,8 +28,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from .api import USER_AGENT from .config import RUNTIME_DIR, USER_AGENT, atomic_write_text, env_var
from .config import RUNTIME_DIR, atomic_write_text
#: mpv input bindings that turn the system media keys into TUI commands. #: mpv input bindings that turn the system media keys into TUI commands.
#: #:
@@ -39,20 +38,20 @@ from .config import RUNTIME_DIR, atomic_write_text
#: list, the spectrum and the desktop "now playing" state) we bounce them back #: list, the spectrum and the desktop "now playing" state) we bounce them back
#: over the IPC socket as ``client-message`` events. #: over the IPC socket as ``client-message`` events.
MEDIA_KEY_BINDINGS = """\ MEDIA_KEY_BINDINGS = """\
# Generated by somafm-tui -- do not edit; it is rewritten on every launch. # Generated by beefy-player -- do not edit; it is rewritten on every launch.
PLAY script-message somafm toggle PLAY script-message beefy toggle
PLAYONLY script-message somafm play PLAYONLY script-message beefy play
PAUSEONLY script-message somafm pause PAUSEONLY script-message beefy pause
STOP script-message somafm stop STOP script-message beefy stop
NEXT script-message somafm next NEXT script-message beefy next
PREV script-message somafm prev PREV script-message beefy prev
""" """
#: Commands a media key may ask for; everything else is ignored. #: Commands a media key may ask for; everything else is ignored.
MEDIA_COMMANDS = frozenset({"play", "pause", "toggle", "stop", "next", "prev"}) MEDIA_COMMANDS = frozenset({"play", "pause", "toggle", "stop", "next", "prev"})
#: Prefix of the ``client-message`` namespace we listen on. #: Prefix of the ``client-message`` namespace we listen on.
MEDIA_MESSAGE_PREFIX = "somafm" MEDIA_MESSAGE_PREFIX = "beefy"
@dataclass @dataclass
@@ -113,7 +112,7 @@ class Player:
) )
return return
self._thread = threading.Thread( self._thread = threading.Thread(
target=self._thread_main, name="somafm-player", daemon=True target=self._thread_main, name="beefy-player", daemon=True
) )
self._thread.start() self._thread.start()
self._ready.wait(10) self._ready.wait(10)
@@ -316,12 +315,12 @@ class Player:
"--cache=yes", "--cache=yes",
"--cache-secs=20", "--cache-secs=20",
"--demuxer-max-bytes=32MiB", "--demuxer-max-bytes=32MiB",
"--audio-client-name=somafm-tui", "--audio-client-name=beefy-player",
f"--user-agent={USER_AGENT}", f"--user-agent={USER_AGENT}",
"--network-timeout=20", "--network-timeout=20",
] ]
command.extend(self._platform_options()) command.extend(self._platform_options())
extra = os.environ.get("SOMAFM_MPV_ARGS") extra = env_var("MPV_ARGS")
if extra: if extra:
command.extend(shlex.split(extra)) command.extend(shlex.split(extra))
@@ -466,7 +465,7 @@ class Player:
self.events.put(PlayerEvent(kind="notice", detail=str(error))) self.events.put(PlayerEvent(kind="notice", detail=str(error)))
def _handle_client_message(self, args: Any) -> None: def _handle_client_message(self, args: Any) -> None:
"""Turn ``script-message somafm <command>`` into a player event. """Turn ``script-message beefy <command>`` into a player event.
mpv's macOS backend feeds the system media keys (and Control Center mpv's macOS backend feeds the system media keys (and Control Center
buttons) into its input system; ``MEDIA_KEY_BINDINGS`` maps them onto buttons) into its input system; ``MEDIA_KEY_BINDINGS`` maps them onto
+86
View File
@@ -0,0 +1,86 @@
"""The catalogue registry.
Beefy Player speaks to internet radio through :class:`RadioProvider`
implementations (see :mod:`beefy_player.providers.base`). Each provider is
registered here once; the app looks its provider up by slug, and ``--provider``
on the command line is validated against :func:`available_providers`.
Adding a catalogue is meant to be a small, contained change:
1. write the package (``beefy_player/providers/<slug>/``) with a
:class:`~beefy_player.providers.base.RadioProvider` subclass,
2. add one instance to :data:`PROVIDERS` below,
3. that is it — the UI, the player, the spectrum and the media session do not
care where the stations came from.
"""
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",
"PROVIDERS",
"RadioProvider",
"available_providers",
"get_provider",
]
#: 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 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.
DEFAULT_PROVIDER = SomaFMProvider.id
def available_providers() -> tuple[str, ...]:
"""Registered provider slugs, for ``--provider`` and the config file."""
return tuple(PROVIDERS)
def get_provider(provider_id: str | None = None) -> RadioProvider:
"""Look a provider up by slug, falling back to the default one.
An unknown slug is a stale config file or a typo, not a reason to refuse to
start; the help screen and ``--list`` always name what is in use.
"""
key = (provider_id or "").strip().lower()
if key in PROVIDERS:
return PROVIDERS[key]
return PROVIDERS[DEFAULT_PROVIDER]
+338
View File
@@ -0,0 +1,338 @@
"""The provider seam: what a radio catalogue has to implement.
Beefy Player is an internet-radio player. Everything the UI needs from a
catalogue — the station list, the song history, the stream URLs — goes through
:class:`RadioProvider`, and the app only ever holds one of those. SomaFM
(:mod:`beefy_player.providers.somafm`) is the first implementation; adding
another station catalogue means writing another subclass and registering it in
:mod:`beefy_player.providers`, with no changes to the app or the widgets.
This module also carries the toolkit a provider needs in practice: an HTTP
client factory and playlist handling (``.pls``/``.m3u`` resolution with an
on-disk cache), which are the same for every catalogue.
"""
from __future__ import annotations
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 Broadcast, Song, Station
__all__ = [
"PLAYLIST_TTL",
"RadioProvider",
"StationPage",
"fetch_json",
"is_direct_stream",
"is_playlist",
"make_client",
"parse_pls",
"quote_station_id",
"reset_playlist_cache",
"resolve_playlist",
]
REQUEST_TIMEOUT = httpx.Timeout(15.0, connect=8.0)
PLAYLIST_TTL = 8 * 3600
_PLAYLIST_CACHE: dict[str, dict[str, Any]] | None = None
def make_client(user_agent: str = "") -> httpx.AsyncClient:
return httpx.AsyncClient(
headers={
"User-Agent": user_agent or config.USER_AGENT,
"Accept": "application/json, */*",
},
timeout=REQUEST_TIMEOUT,
follow_redirects=True,
)
def reset_playlist_cache() -> None:
"""Forget the in-memory playlist cache (the tests use this)."""
global _PLAYLIST_CACHE
_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
# --------------------------------------------------------------------------- #
class RadioProvider(ABC):
"""A station catalogue plus its streams and song history.
Providers are stateless singletons registered in
:data:`beefy_player.providers.PROVIDERS`; the app keeps one for its whole
lifetime and never instantiates another.
"""
#: Stable slug, used in caches, config and ``--provider``.
id: str = ""
#: Display name, e.g. ``"SomaFM"``.
name: str = ""
#: Bare domain, for docs and the help screen.
homepage: str = ""
#: Brand shown above the station list before anything is tuned in.
brand: str = ""
#: One-liner under the brand.
tagline: str = ""
#: Where stations come from, e.g. ``"somafm.com/channels.json"``.
station_source: str = ""
#: Where the song history comes from, e.g. ``"songs/<station>.json"``.
history_source: str = ""
#: ``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
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
"""Fetch the live station list and refresh the on-disk cache."""
@abstractmethod
def load_cached_stations(self) -> list[Station]:
"""The last good station list from disk, or ``[]``."""
@abstractmethod
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
async def fetch_history(
self, client: httpx.AsyncClient, station_id: str
) -> list[Song]:
"""Recently played songs, newest first (falls back to the cache)."""
@abstractmethod
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]:
"""Guessed stream URLs, used when the catalogue is unreachable."""
return []
async def stream_candidates(
self, client: httpx.AsyncClient, station: Station
) -> list[str]:
"""Direct stream URLs to try, best first.
Resolves the station's own playlists (a couple of ``.pls`` files at
most) and appends :meth:`fallback_urls` in case none of them answer.
"""
urls: list[str] = []
playlists = station.sorted_playlists()
for entry in playlists[:3]:
url = entry.get("url", "")
resolved = await resolve_playlist(client, url)
for candidate in resolved:
if candidate not in urls:
urls.append(candidate)
if not resolved and url and url not in urls:
urls.append(url)
for entry in playlists[3:]:
url = entry.get("url", "")
if url and url not in urls:
urls.append(url)
for url in self.fallback_urls(station):
if url not in urls:
urls.append(url)
return urls[:10]
# --------------------------------------------------------------------------- #
# playlists
# --------------------------------------------------------------------------- #
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"))
def is_direct_stream(url: str) -> bool:
return url.startswith(("http://", "https://")) and not is_playlist(url)
def parse_pls(text: str) -> list[str]:
urls: list[str] = []
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
match = re.match(r"(?i)^file\d+\s*=\s*(\S+)$", line)
candidate = match.group(1) if match else line
if candidate.startswith(("http://", "https://")) and candidate not in urls:
urls.append(candidate)
return urls
def _playlist_cache() -> dict[str, dict[str, Any]]:
global _PLAYLIST_CACHE
if _PLAYLIST_CACHE is None:
raw = config.read_json(config.PLAYLIST_CACHE)
_PLAYLIST_CACHE = raw if isinstance(raw, dict) else {}
return _PLAYLIST_CACHE
async def resolve_playlist(client: httpx.AsyncClient, url: str) -> list[str]:
"""Turn a ``.pls`` URL into direct icecast URLs (cached for a few hours)."""
if not url:
return []
if not is_playlist(url):
return [url]
cache = _playlist_cache()
now = time.time()
entry = cache.get(url)
if isinstance(entry, dict):
fetched = entry.get("ts")
urls = entry.get("urls")
if (
isinstance(fetched, (int, float))
and now - fetched < PLAYLIST_TTL
and isinstance(urls, list)
):
cached_urls = [item for item in urls if isinstance(item, str)]
if cached_urls:
return cached_urls
try:
response = await client.get(url, headers={"Accept": "*/*"})
response.raise_for_status()
text = response.text
except httpx.HTTPError:
return []
urls = parse_pls(text)
if urls:
cache[url] = {"ts": now, "urls": urls}
config.write_json(config.PLAYLIST_CACHE, cache)
return urls
def quote_station_id(station_id: str) -> str:
"""Percent-encode a station id for use in a provider URL."""
return quote(station_id, safe="")
async def fetch_json(
client: httpx.AsyncClient, url: str, *, attempts: int = 2, pause: float = 0.6
) -> Any:
"""``GET url`` and decode JSON, retrying once: flaky mirrors are normal."""
last_error: Exception | None = None
for attempt in range(attempts):
try:
response = await client.get(url)
response.raise_for_status()
return response.json()
except (httpx.HTTPError, ValueError) as exc: # includes JSON errors
last_error = exc
if attempt + 1 < attempts:
await asyncio.sleep(pause)
raise RuntimeError(f"could not fetch {url}: {last_error}")
@@ -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")
+60
View File
@@ -0,0 +1,60 @@
"""SomaFM: the station catalogue Beefy Player was born with.
Everything SomaFM-specific lives in this package — the ``channels.json``
schema, the ``songs/<station>.json`` history, the ``.pls`` layout and the
guessed icecast mirrors. The rest of the app only sees
:class:`SomaFMProvider`, which implements
:class:`beefy_player.providers.base.RadioProvider`.
"""
from __future__ import annotations
import httpx
from ... import config
from ...models import Song, Station
from ..base import RadioProvider
from . import api, fallback
from . import hero as hero_art
__all__ = ["SomaFMProvider"]
class SomaFMProvider(RadioProvider):
id = "somafm"
name = "SomaFM"
homepage = "somafm.com"
brand = "SOMA FM"
tagline = "listener-supported radio · san francisco"
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
# where we come from.
return f"{config.USER_AGENT}; +https://somafm.com"
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
return await api.fetch_channels(client)
def load_cached_stations(self) -> list[Station]:
return api.load_cached_channels()
def fallback_stations(self) -> list[Station]:
return api.parse_channels({"channels": fallback.fallback_channels()})
async def fetch_history(
self, client: httpx.AsyncClient, station_id: str
) -> list[Song]:
return await api.fetch_history(client, station_id)
def load_cached_history(self, station_id: str) -> list[Song]:
return api.load_cached_songs(station_id)
def fallback_urls(self, station: Station) -> list[str]:
return api.synthetic_mirrors(station.id)
+206
View File
@@ -0,0 +1,206 @@
"""The public SomaFM endpoints, and nothing but.
* ``https://somafm.com/channels.json`` -> every station + listener counts
* ``https://somafm.com/<station>.pls`` -> tiny PLS playlists (resolved to ice URLs)
* ``https://somafm.com/songs/<station>.json`` -> the "last played" song history
All parsing is deliberately forgiving: SomaFM has changed field names before
and we would rather show a slightly degraded station than crash. Nothing
outside :mod:`beefy_player.providers.somafm` should import this module — the
app talks to :class:`beefy_player.providers.somafm.SomaFMProvider` instead.
"""
from __future__ import annotations
import re
import time
from collections.abc import Iterable
from typing import Any
import httpx
from ... import config
from ...models import Song, Station, parse_date, parse_last_playing, to_int
from ..base import fetch_json, quote_station_id
CHANNELS_URL = "https://somafm.com/channels.json"
SONGS_URL = "https://somafm.com/songs/{station}.json"
# --------------------------------------------------------------------------- #
# channels
# --------------------------------------------------------------------------- #
def station_from_payload(item: dict[str, Any]) -> Station | None:
if not isinstance(item, dict):
return None
channel_id = item.get("id")
if not isinstance(channel_id, str) or not channel_id.strip():
return None
channel_id = channel_id.strip()
playlists: list[dict[str, str]] = []
raw_playlists = item.get("playlists")
if isinstance(raw_playlists, list):
for entry in raw_playlists:
if not isinstance(entry, dict):
continue
url = entry.get("url")
if not isinstance(url, str) or not url.startswith(("http://", "https://")):
continue
playlists.append(
{
"url": url,
"format": str(entry.get("format") or "").strip().lower(),
"quality": str(entry.get("quality") or "").strip().lower(),
}
)
station = Station(
id=channel_id,
title=str(item.get("title") or channel_id).strip(),
description=str(item.get("description") or "").strip(),
genre=str(item.get("genre") or "").strip(),
dj=str(item.get("dj") or "").strip(),
image=str(item.get("image") or item.get("largeimage") or "").strip(),
listeners=to_int(item.get("listeners")),
playlists=playlists,
provider="somafm",
)
last_playing = item.get("lastPlaying")
if isinstance(last_playing, str):
station.last_playing = last_playing.strip()
station.now_playing = parse_last_playing(station.last_playing)
if not station.image:
station.image = f"https://somafm.com/logos/256/{channel_id}256.png"
return station
def parse_channels(payload: Any) -> list[Station]:
if isinstance(payload, dict):
items = payload.get("channels")
elif isinstance(payload, list):
items = payload
else:
items = None
if not isinstance(items, list):
return []
stations: list[Station] = []
seen: set[str] = set()
for item in items:
station = station_from_payload(item)
if station is None or station.id in seen:
continue
seen.add(station.id)
stations.append(station)
return stations
async def fetch_channels(client: httpx.AsyncClient) -> list[Station]:
try:
payload = await fetch_json(client, CHANNELS_URL)
except RuntimeError as exc:
raise RuntimeError(f"could not fetch SomaFM channels: {exc}") from None
stations = parse_channels(payload)
if not stations:
raise RuntimeError("SomaFM returned an empty channel list")
config.write_json(config.CHANNELS_CACHE, payload)
return stations
def load_cached_channels() -> list[Station]:
return parse_channels(config.read_json(config.CHANNELS_CACHE))
# --------------------------------------------------------------------------- #
# song history
# --------------------------------------------------------------------------- #
def parse_songs(payload: Any) -> list[Song]:
if isinstance(payload, dict):
items = payload.get("songs")
elif isinstance(payload, list):
items = payload
else:
items = None
if not isinstance(items, list):
return []
songs: list[Song] = []
for entry in items:
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("albumArt") or entry.get("albumart") or entry.get("art") or ""
).strip(),
played_at=parse_date(entry.get("date")),
)
if song.title or song.artist:
songs.append(song)
# Newest first; the upstream feed is already sorted but let's be sure.
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_cache_path(station_id: str):
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", station_id)
return config.SONGS_CACHE_DIR / f"{safe}.json"
def load_cached_songs(station_id: str) -> list[Song]:
raw = config.read_json(_songs_cache_path(station_id))
if isinstance(raw, dict):
return parse_songs(raw.get("songs"))
return []
def _songs_to_cache(songs: Iterable[Song]) -> list[dict[str, Any]]:
return [
{
"title": song.title,
"artist": song.artist,
"album": song.album,
"albumArt": song.art_url,
"date": song.played_at.isoformat() if song.played_at else "",
}
for song in songs
]
async def fetch_history(client: httpx.AsyncClient, station_id: str) -> list[Song]:
url = SONGS_URL.format(station=quote_station_id(station_id))
try:
response = await client.get(url)
response.raise_for_status()
payload = response.json()
except (httpx.HTTPError, ValueError):
return load_cached_songs(station_id)
songs = parse_songs(payload)
if songs:
config.write_json(
_songs_cache_path(station_id),
{"fetched": time.time(), "songs": _songs_to_cache(songs)},
)
return songs
return load_cached_songs(station_id)
# --------------------------------------------------------------------------- #
# streams
# --------------------------------------------------------------------------- #
def synthetic_mirrors(station_id: str) -> list[str]:
"""Direct icecast URLs, handy when the PLS files are unreachable."""
urls: list[str] = []
for suffix in ("-128-mp3", "-64-aac"):
for host in ("ice1", "ice2", "ice3", "ice4"):
urls.append(f"https://{host}.somafm.com/{station_id}{suffix}")
return urls
File renamed without changes.
+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)
@@ -7,9 +7,9 @@ leaves the speakers (status ``SYNC``).
That capture is platform specific: That capture is platform specific:
* **Linux** — ``parec`` records the PulseAudio/PipeWire monitor of the default * **Linux** — ``parec`` records the PulseAudio/PipeWire monitor of the default
sink (or ``SOMAFM_SPECTRUM_DEVICE``). sink (or ``BEEFY_SPECTRUM_DEVICE``).
* **macOS** — a native Core Audio *process tap* records the default output * **macOS** — a native Core Audio *process tap* records the default output
device (see :mod:`somafm_tui.macos_tap`). Nothing to install: no virtual device (see :mod:`beefy_player.macos_tap`). Nothing to install: no virtual
loopback, no Multi-Output Device, no helper binary. macOS asks for loopback, no Multi-Output Device, no helper binary. macOS asks for
**System Audio Recording** permission the first time; while it is missing **System Audio Recording** permission the first time; while it is missing
the tap delivers silence, which is treated as a failed capture. the tap delivers silence, which is treated as a failed capture.
@@ -35,7 +35,7 @@ from dataclasses import dataclass
import numpy as np import numpy as np
from . import macos_tap from . import macos_tap
from .api import USER_AGENT from .config import USER_AGENT, env_var
SAMPLE_RATE = 44100 SAMPLE_RATE = 44100
FFT_SIZE = 2048 FFT_SIZE = 2048
@@ -47,9 +47,19 @@ MAX_FREQ = 16000.0
SOURCE_MODES = ("auto", "monitor", "stream", "sim") SOURCE_MODES = ("auto", "monitor", "stream", "sim")
#: ~46 ms of all-zero audio per block; give an output capture ~6 s to prove #: ~46 ms of all-zero audio per block; give an output capture ~6 s to prove
#: itself before falling back to the stream tap. #: itself before falling back to the stream tap. This only applies before the
#: capture has ever delivered real audio. Once a capture has worked, silence
#: means the output really is silent (or mpv is rebuffering), so SYNC is kept
#: and the bars simply fall instead of switching to the drifting stream tap.
SILENT_MONITOR_BLOCKS = 130 SILENT_MONITOR_BLOCKS = 130
#: How long a monitor may deliver no data at all before the stream tap takes
#: over. Kept longer than a normal station change.
MONITOR_IDLE_SECONDS = 10.0
#: How often a quiet monitor pipe is polled so stop/URL changes stay responsive.
MONITOR_POLL_SECONDS = 0.5
#: Frames per analysis block for the macOS tap (float32, ~43 ms at 48 kHz). #: Frames per analysis block for the macOS tap (float32, ~43 ms at 48 kHz).
TAP_BLOCK_FRAMES = 2048 TAP_BLOCK_FRAMES = 2048
@@ -58,7 +68,7 @@ TAP_BLOCK_FRAMES = 2048
#: the window playback gets to start before the stream tap takes over. #: the window playback gets to start before the stream tap takes over.
TAP_IDLE_SECONDS = 10.0 TAP_IDLE_SECONDS = 10.0
#: How long the stream tap covers for us before the tap is tried again. #: How long the stream tap covers for us before an output capture is retried.
TAP_RETRY_SECONDS = 60.0 TAP_RETRY_SECONDS = 60.0
@@ -118,8 +128,18 @@ class SpectrumEngine:
self._url: str | None = None self._url: str | None = None
self._proc: subprocess.Popen[bytes] | None = None self._proc: subprocess.Popen[bytes] | None = None
self._capture_kind: str | None = None self._capture_kind: str | None = None
#: Incremented whenever the source changes; captures from an older
#: generation must not affect the new one (or latch a failure).
self._generation = 0
self._reset_pending = False
#: True once any output capture has ever delivered real audio. A later
#: silent capture is then a gap, not a misconfigured device.
self._monitor_worked = False
self._monitor_failed = False self._monitor_failed = False
#: Next time an output capture may be tried (shared by parec and tap).
self._tap_retry_at = 0.0 self._tap_retry_at = 0.0
#: Why ``_capture`` last returned ("eof", "idle", "silent", ...).
self._capture_reason = "eof"
self._monitor_device_cache: str | None = None self._monitor_device_cache: str | None = None
self._notice: str | None = None self._notice: str | None = None
self._lock = threading.Lock() self._lock = threading.Lock()
@@ -144,25 +164,42 @@ class SpectrumEngine:
if self._thread is not None: if self._thread is not None:
return return
self._thread = threading.Thread( self._thread = threading.Thread(
target=self._run, name="somafm-spectrum", daemon=True target=self._run, name="beefy-player-spectrum", daemon=True
) )
self._thread.start() self._thread.start()
def set_source(self, url: str | None) -> None: def set_source(self, url: str | None) -> None:
"""Start analysing playback, or idle when ``url`` is ``None``. """Start analysing playback, or idle when ``url`` is ``None``.
The URL is only a fallback source for the stream tap. A monitor A station change is a clean slate: any capture belonging to the old
capture is tied to the actual audio output, so it deliberately keeps station is retired, a previous monitor failure is forgotten, and the
running when the station changes underneath it. analyser's display state is reset. That way every tune gets a fresh
chance to lock onto the real output (SYNC) instead of inheriting the
drifting stream tap (FFT) or the synthesized fallback (SIM).
""" """
url = url or None url = url or None
with self._lock: with self._lock:
changed = url != self._url changed = url != self._url
self._url = url self._url = url
kind = self._capture_kind if changed:
self._request_reset()
if url is None: if url is None:
self._status = "idle" self._status = "idle"
if changed and (url is None or kind != "monitor"): if changed:
# Unblock a stream read and retire a subprocess monitor. A tap has
# no process; its generation check makes it stop and restart.
self._kill_proc()
def reset(self) -> None:
"""Give the output capture a clean slate (called when tuning).
Forgets a previous monitor failure - including a hard one that set
``_monitor_failed`` - and clears the displayed state, so the next
capture may use the real output (SYNC) again. Unlike :meth:`set_source`
this does not change the source, so re-tuning the same station works.
"""
with self._lock:
self._request_reset()
self._kill_proc() self._kill_proc()
def snapshot(self) -> Frame: def snapshot(self) -> Frame:
@@ -213,6 +250,41 @@ class SpectrumEngine:
with self._lock: with self._lock:
return self._url return self._url
def _request_reset(self) -> None:
"""Arm a clean restart; the caller must hold ``self._lock``."""
self._generation += 1
self._reset_pending = True
self._monitor_failed = False
self._tap_retry_at = 0.0
self._capture_reason = "eof"
# The default sink may have changed along with the stream; resolve it
# again for the next monitor capture.
self._monitor_device_cache = None
def _read_generation(self) -> int:
with self._lock:
return self._generation
def _take_reset(self) -> bool:
with self._lock:
pending, self._reset_pending = self._reset_pending, False
return pending
def _reset_analysis(self) -> None:
"""Drop the previous station's samples and display state.
Only called from the analyser thread (or before it starts), so the
history buffer needs no lock; the values read by ``snapshot`` do.
"""
self._history[:] = 0.0
self._sim_phase = 0.0
with self._lock:
self._levels[:] = 0.0
self._peaks[:] = 0.0
self._wave[:] = 0.0
self._level = 0.0
self._gain = -24.0
def _kill_proc(self) -> None: def _kill_proc(self) -> None:
with self._lock: with self._lock:
proc = self._proc proc = self._proc
@@ -227,13 +299,11 @@ class SpectrumEngine:
def _use_monitor(self) -> bool: def _use_monitor(self) -> bool:
if self._monitor_failed or self.source_mode in ("sim", "stream"): if self._monitor_failed or self.source_mode in ("sim", "stream"):
return False return False
kind = self._monitor_kind() if self._monitor_kind() is None:
if kind is None:
return False return False
if kind != "tap": # A capture that had nothing to give us (a station change leaves the
return True # sink quiet while mpv buffers, a tap is idle before playback starts)
# The tap needs the default output to actually render something; when # is retried later instead of being written off for the session.
# it does not, the stream tap covers us until the retry deadline.
return time.monotonic() >= self._tap_retry_at return time.monotonic() >= self._tap_retry_at
def _monitor_kind(self) -> str | None: def _monitor_kind(self) -> str | None:
@@ -245,7 +315,7 @@ class SpectrumEngine:
return None return None
def _monitor_device(self) -> str: def _monitor_device(self) -> str:
override = os.environ.get("SOMAFM_SPECTRUM_DEVICE", "").strip() override = env_var("SPECTRUM_DEVICE")
if override: if override:
return override return override
if self._monitor_device_cache is not None: if self._monitor_device_cache is not None:
@@ -281,7 +351,7 @@ class SpectrumEngine:
"--channels=1", "--channels=1",
"--raw", "--raw",
"--latency-msec=30", "--latency-msec=30",
"--client-name=somafm-tui", "--client-name=beefy-player",
] ]
def _spawn( def _spawn(
@@ -316,6 +386,8 @@ class SpectrumEngine:
def _run(self) -> None: def _run(self) -> None:
while not self._stop.is_set(): while not self._stop.is_set():
if self._take_reset():
self._reset_analysis()
url = self._read_url() url = self._read_url()
if not url: if not url:
self._idle_frame() self._idle_frame()
@@ -327,15 +399,21 @@ class SpectrumEngine:
continue continue
if self._use_monitor(): if self._use_monitor():
generation = self._read_generation()
outcome = self._capture_monitor(url) outcome = self._capture_monitor(url)
if generation != self._read_generation():
# The station changed while this capture was running; its
# result belongs to the old source. Start over cleanly.
continue
if outcome == "done": if outcome == "done":
continue continue
if outcome == "retry": if outcome == "retry":
# Nothing was rendered (yet): try the tap again later. # Nothing was rendered (yet), or the sink went quiet while
# the station changed: try the output capture again later.
self._tap_retry_at = time.monotonic() + TAP_RETRY_SECONDS self._tap_retry_at = time.monotonic() + TAP_RETRY_SECONDS
else: else:
# Device gone, or the macOS audio-capture permission was # Hard failure (the capture never produced audio): do not
# never granted: do not retry in this session. # retry until the source changes (which resets this).
self._monitor_failed = True self._monitor_failed = True
if self._stop.is_set(): if self._stop.is_set():
break break
@@ -343,20 +421,36 @@ class SpectrumEngine:
continue continue
if not self._ffmpeg: if not self._ffmpeg:
self._simulate_for(url, 0.05) # Keep re-checking: a pending output-capture retry may become
# due while we are showing the synthesized fallback.
self._simulate_for(url, 0.05, duration=5.0)
continue continue
generation = self._read_generation()
proc = self._spawn(self._command(url), "stream") proc = self._spawn(self._command(url), "stream")
if proc is None: if proc is None:
with self._lock: with self._lock:
self._status = "sim" self._status = "sim"
self._simulate_for(url, 0.06, duration=5.0) self._simulate_for(url, 0.06, duration=5.0)
continue continue
self._capture(proc, url, stop_on_url_change=True, status="live") self._capture(
proc,
url,
stop_on_url_change=True,
status="live",
generation=generation,
)
self._stop_proc(proc) self._stop_proc(proc)
if self._stop.is_set(): if self._stop.is_set():
break break
if generation != self._read_generation():
continue
# The stream tap only covers periods where no output capture is
# available. Once the retry deadline has passed, go back around
# and try the real thing instead of simulating on top of the tap.
if self._use_monitor():
continue
if self._read_url() == url: if self._read_url() == url:
with self._lock: with self._lock:
self._status = "sim" self._status = "sim"
@@ -407,25 +501,44 @@ class SpectrumEngine:
"""Capture the real audio output. """Capture the real audio output.
Returns ``"done"`` when playback stopped, ``"failed"`` when the Returns ``"done"`` when playback stopped, ``"failed"`` when the
monitor is unusable, and ``"retry"`` when it merely had nothing to capture never produced audio (the device is gone or the sink is not
capture yet. routed into it), and ``"retry"`` when it is temporarily unavailable.
""" """
generation = self._read_generation()
if self._monitor_kind() == "tap": if self._monitor_kind() == "tap":
return self._capture_tap() outcome = self._capture_tap()
if generation != self._read_generation():
return "done"
return outcome
command = self._monitor_command() command = self._monitor_command()
proc = self._spawn(command, "monitor") if command else None proc = self._spawn(command, "monitor") if command else None
if proc is None: if proc is None:
return "failed" return "failed"
try: try:
self._capture(proc, url, stop_on_url_change=False, status="monitor") self._capture(
proc,
url,
stop_on_url_change=False,
status="monitor",
generation=generation,
)
finally: finally:
self._stop_proc(proc) self._stop_proc(proc)
if generation != self._read_generation():
# Deliberately stopped/restarted between stations; not a failure.
return "done"
if self._stop.is_set() or self._read_url() is None: if self._stop.is_set() or self._read_url() is None:
return "done" return "done"
# A sink that is quiet for a moment (mpv rebuffers a new station, a
# device is briefly suspended) is not broken: let the stream tap cover
# us until the next retry instead of permanently writing SYNC off.
if self._capture_reason == "idle":
return "retry"
return "failed" return "failed"
def _capture_tap(self) -> str: def _capture_tap(self) -> str:
"""Capture the native macOS output tap.""" """Capture the native macOS output tap."""
generation = self._read_generation()
tap = macos_tap.SystemAudioTap() tap = macos_tap.SystemAudioTap()
try: try:
tap.start() tap.start()
@@ -438,21 +551,28 @@ class SpectrumEngine:
self._status = "monitor" self._status = "monitor"
try: try:
self._retune(tap.sample_rate) self._retune(tap.sample_rate)
return self._pump_tap(tap) return self._pump_tap(tap, generation)
finally: finally:
tap.stop() tap.stop()
with self._lock: with self._lock:
if self._capture_kind == "monitor": if self._capture_kind == "monitor":
self._capture_kind = None self._capture_kind = None
def _pump_tap(self, tap: macos_tap.SystemAudioTap) -> str: def _pump_tap(
self, tap: macos_tap.SystemAudioTap, generation: int | None = None
) -> str:
"""Feed tap audio to the analyser until playback stops or it fails.""" """Feed tap audio to the analyser until playback stops or it fails."""
if generation is None:
generation = self._read_generation()
channels = max(1, tap.channels) channels = max(1, tap.channels)
block_bytes = TAP_BLOCK_FRAMES * max(4, tap.bytes_per_frame) block_bytes = TAP_BLOCK_FRAMES * max(4, tap.bytes_per_frame)
buffer = bytearray() buffer = bytearray()
silent_blocks = 0 silent_blocks = 0
ever_worked = self._monitor_worked
last_audio = time.monotonic() last_audio = time.monotonic()
while not self._stop.is_set(): while not self._stop.is_set():
if self._read_generation() != generation:
return "done"
if self._read_url() is None: if self._read_url() is None:
return "done" return "done"
chunk = tap.read(0.5) chunk = tap.read(0.5)
@@ -473,19 +593,24 @@ class SpectrumEngine:
while len(buffer) >= block_bytes: while len(buffer) >= block_bytes:
block = bytes(buffer[:block_bytes]) block = bytes(buffer[:block_bytes])
del buffer[:block_bytes] del buffer[:block_bytes]
# A tap that was never granted permission is created happily
# but only ever hands out digital silence; detect that here.
if not block.strip(b"\x00"): if not block.strip(b"\x00"):
silent_blocks += 1 silent_blocks += 1
if silent_blocks >= SILENT_MONITOR_BLOCKS: # A tap that was never granted permission is created
# happily but only ever hands out digital silence; detect
# that here. Silence after real audio, however, is just a
# station change or an actually silent output, so keep
# monitoring (the bars fall by themselves).
if not ever_worked and silent_blocks >= SILENT_MONITOR_BLOCKS:
self._notify( self._notify(
"macOS is not letting the app record audio — allow " "macOS is not letting the app record audio — allow "
"'System Audio Recording' for your terminal in " "'System Audio Recording' for your terminal in "
"System Settings, then restart (using FFT)" "System Settings, then restart (using FFT)"
) )
return "failed" return "failed"
continue else:
silent_blocks = 0 silent_blocks = 0
ever_worked = True
self._monitor_worked = True
self.feed_audio(block, channels, "monitor") self.feed_audio(block, channels, "monitor")
return "done" return "done"
@@ -496,56 +621,103 @@ class SpectrumEngine:
*, *,
stop_on_url_change: bool, stop_on_url_change: bool,
status: str, status: str,
generation: int | None = None,
) -> bool: ) -> bool:
"""Read PCM until playback stops, the source changes, or the process dies.""" """Read PCM until playback stops, the source changes, or the process dies.
``self._capture_reason`` records why the loop ended so the caller can
tell a capture that has gone for good (``"eof"``/``"dead"``) from one
that is merely quiet while a station is being tuned (``"idle"`` /
``"restart"``).
"""
stdout = proc.stdout stdout = proc.stdout
if stdout is None: if stdout is None:
self._capture_reason = "eof"
return False return False
if generation is None:
generation = self._read_generation()
fd = stdout.fileno() fd = stdout.fileno()
got_data = False got_data = False
buffer = bytearray() buffer = bytearray()
silent_blocks = 0 silent_blocks = 0
ever_worked = self._monitor_worked
last_data = time.monotonic()
reason = "stop"
while not self._stop.is_set(): while not self._stop.is_set():
if self._read_generation() != generation:
reason = "restart"
break
current = self._read_url() current = self._read_url()
if current is None: if current is None:
reason = "idle"
break break
if stop_on_url_change and current != url: if stop_on_url_change and current != url:
reason = "url"
break
# The stream tap yields once an output capture is due for a retry,
# so a monitor with a momentary lapse does not keep us on the
# (drifting) stream tap for the rest of the session.
if status == "live" and self._use_monitor():
reason = "monitor"
break break
# A monitor can go quiet if the device disappears; don't block the # A monitor can go quiet while mpv rebuffers a newly tuned station,
# analyser thread forever. os.read returns whatever is available # or when the device disappears. Don't block the analyser thread
# (instead of waiting for a full 4 KiB), which keeps latency low. # forever, but give silence a fair chance to turn back into audio.
# os.read returns whatever is available (instead of waiting for a
# full 4 KiB), which keeps latency low.
if status == "monitor" and os.name == "posix": if status == "monitor" and os.name == "posix":
try: try:
ready, _, _ = select.select([fd], [], [], 2.0) ready, _, _ = select.select(
[fd], [], [], MONITOR_POLL_SECONDS
)
except (OSError, ValueError): except (OSError, ValueError):
ready = [fd] ready = [fd]
if not ready: if not ready:
if proc.poll() is not None:
reason = "eof"
break break
if time.monotonic() - last_data >= MONITOR_IDLE_SECONDS:
reason = "idle"
break
continue
try: try:
data = os.read(fd, READ_BYTES) data = os.read(fd, READ_BYTES)
except OSError: except OSError:
reason = "eof"
break break
if not data: if not data:
reason = "eof"
break break
now = time.monotonic()
last_data = now
buffer.extend(data) buffer.extend(data)
# Process fixed-size blocks so attack/decay smoothing is tied to # Process fixed-size blocks so attack/decay smoothing is tied to
# time (~46 ms per block), regardless of how the pipe fragments. # time (~46 ms per block), regardless of how the pipe fragments.
while len(buffer) >= READ_BYTES: while len(buffer) >= READ_BYTES:
block = bytes(buffer[:READ_BYTES]) block = bytes(buffer[:READ_BYTES])
del buffer[:READ_BYTES] del buffer[:READ_BYTES]
# A monitor that was never granted permission (macOS) or that
# nobody routes into (PulseAudio) delivers endless digital
# silence. SYNC bars that never move are worse than the
# (slightly drifting) stream tap, so give up and let the
# caller fall back.
if status == "monitor": if status == "monitor":
silent_blocks = silent_blocks + 1 if block.count(0) == len(block) else 0 if block.count(0) == len(block):
if silent_blocks >= SILENT_MONITOR_BLOCKS: silent_blocks += 1
else:
silent_blocks = 0
ever_worked = True
self._monitor_worked = True
# A capture that was never granted permission (macOS) or
# that nobody routes into (PulseAudio) delivers endless
# digital silence. SYNC bars that never move are worse
# than the (slightly drifting) stream tap, so give up on a
# backend that has never worked. Once it has worked,
# silence is real (or mpv rebuffering): keep SYNC and let
# the bars fall.
if not ever_worked and silent_blocks >= SILENT_MONITOR_BLOCKS:
self._capture_reason = "dead"
return False return False
got_data = True got_data = True
self.feed_pcm(block, status) self.feed_pcm(block, status)
self._capture_reason = reason
return got_data return got_data
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -668,6 +840,10 @@ class SpectrumEngine:
while not self._stop.is_set(): while not self._stop.is_set():
if self._read_url() != url: if self._read_url() != url:
return return
# Stop pretending as soon as a real output capture is available
# again (e.g. the retry deadline expired during the fallback).
if self._use_monitor():
return
self._simulate_step() self._simulate_step()
if self._stop.wait(delay): if self._stop.wait(delay):
return return
@@ -1,4 +1,4 @@
/* somafm-tui — stock terminal palette, transparent backgrounds. /* beefy-player — stock terminal palette, transparent backgrounds.
Colours are ANSI names only; this file never paints a background. */ Colours are ANSI names only; this file never paints a background. */
Screen { Screen {
@@ -76,9 +76,14 @@ StationList {
color: ansi_default; color: ansi_default;
width: 1fr; width: 1fr;
height: 1fr; height: 1fr;
border: round ansi_bright_black; background: ansi_default;
border-title-color: ansi_default; }
padding: 0 1;
SchedulePanel {
color: ansi_default;
width: 1fr;
height: 1fr;
display: none;
background: ansi_default; background: ansi_default;
} }
@@ -86,8 +91,30 @@ StagePanel {
color: ansi_default; color: ansi_default;
layout: vertical; layout: vertical;
width: 1fr; width: 1fr;
border: round ansi_bright_black; background: ansi_default;
border-title-color: ansi_default; }
#stage_frame_top,
#stage_frame_bottom {
height: 1;
background: ansi_default;
}
#stage_middle {
height: 1fr;
background: ansi_default;
}
#stage_frame_left,
#stage_frame_right {
width: 1;
height: 1fr;
background: ansi_default;
}
#stage_body {
width: 1fr;
height: 1fr;
padding: 0 1; padding: 0 1;
background: ansi_default; background: ansi_default;
} }
@@ -132,9 +159,6 @@ SpectrumPanel {
color: ansi_default; color: ansi_default;
height: 2fr; height: 2fr;
min-height: 6; min-height: 6;
border: round ansi_bright_black;
border-title-color: ansi_default;
padding: 0 1;
background: ansi_default; background: ansi_default;
} }
@@ -151,12 +175,40 @@ ModalScreen {
background: ansi_default; background: ansi_default;
} }
#help { #providers {
color: ansi_default; color: ansi_default;
width: 78; width: 66;
max-width: 100%;
height: auto; height: auto;
max-height: 100%;
border: round ansi_bright_black; border: round ansi_bright_black;
border-title-color: ansi_default; border-title-color: ansi_default;
padding: 1 2; 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; background: ansi_default;
} }
+1
View File
@@ -0,0 +1 @@
"""Custom Textual widgets for Beefy Player."""
@@ -22,13 +22,13 @@ from textual.strip import Strip
from textual.widget import Widget from textual.widget import Widget
from .. import artwork from .. import artwork
from .common import art_lines from .common import active_stops, art_lines
_NULL_STYLE = Style() _NULL_STYLE = Style()
_NULL_CONTROL = ((ControlType.CURSOR_FORWARD, 0),) _NULL_CONTROL = ((ControlType.CURSOR_FORWARD, 0),)
if TYPE_CHECKING: if TYPE_CHECKING:
from ..app import SomaFMApp from ..app import BeefyPlayerApp
_IMAGE_IDS = itertools.count(random.randrange(1, 2**31 - 1)) _IMAGE_IDS = itertools.count(random.randrange(1, 2**31 - 1))
@@ -308,19 +308,26 @@ class ArtPane(Widget):
self._last_data: bytes | None = None self._last_data: bytes | None = None
self._last_size: tuple[int, int] = (0, 0) self._last_size: tuple[int, int] = (0, 0)
self._mosaic_key = "" self._mosaic_key = ""
#: True while a modal is up: the inline image would sit over the menu.
self._suspended = False
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# mosaic background # mosaic background
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def render(self) -> RenderableType: def render(self) -> RenderableType:
app = cast("SomaFMApp", self.app) app = cast("BeefyPlayerApp", self.app)
station = app.current station = app.current
if station is None: if station is None:
return Text("") return Text("")
width = max(1, self.size.width) width = max(1, self.size.width)
height = max(1, self.size.height) 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 # image overlay
@@ -337,11 +344,29 @@ class ArtPane(Widget):
def on_unmount(self) -> None: def on_unmount(self) -> None:
self._drop_image_widget() 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: def sync_image(self, *, force: bool = False) -> None:
# Keep the mosaic behind the image in sync with the current track. # Keep the mosaic behind the image in sync with the current track.
self.refresh() self.refresh()
if self._suspended:
return
app = cast("SomaFMApp", self.app) app = cast("BeefyPlayerApp", self.app)
station = app.current station = app.current
data = app.artwork_bytes data = app.artwork_bytes
width = max(1, self.size.width) width = max(1, self.size.width)
@@ -19,23 +19,20 @@ from zoneinfo import ZoneInfo
from rich.cells import cell_len from rich.cells import cell_len
from rich.text import Text from rich.text import Text
from ..palettes import (
DEFAULT_PALETTE,
palette_stops,
)
LOCALTIME_PATH = "/etc/localtime" LOCALTIME_PATH = "/etc/localtime"
_UTC_NAMES = {"UTC", "UCT", "GMT", "ETC/UTC", "ETC/UCT", "ETC/GMT", "Z"} _UTC_NAMES = {"UTC", "UCT", "GMT", "ETC/UTC", "ETC/UCT", "ETC/GMT", "Z"}
LEVELS = " ▁▂▃▄▅▆▇█" LEVELS = " ▁▂▃▄▅▆▇█"
# Bottom-to-top spectrum colours, picked from the terminal's stock ANSI palette. def active_stops(app: object) -> tuple[str, ...]:
SPECTRUM_STOPS = ( """The colour ladder the app is currently drawing with."""
"cyan", return palette_stops(getattr(app, "palette", DEFAULT_PALETTE))
"blue",
"magenta",
"bright_magenta",
"yellow",
"bright_yellow",
"red",
)
ART_STOPS = ("blue", "magenta", "bright_magenta", "yellow", "cyan", "green")
STATE_STYLE = { STATE_STYLE = {
"playing": ("●", "green"), "playing": ("●", "green"),
@@ -182,7 +179,9 @@ def state_glyph(state: str) -> tuple[str, str]:
return STATE_STYLE.get(state, STATE_STYLE["stopped"]) 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. """Procedural album art as a solid ANSI-colour mosaic.
Filled block glyphs are used (never cell backgrounds), so the cover is 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) nx = column / max(1, width - 1)
wave = 0.5 + 0.5 * math.sin(nx * 2.9 + ny * 2.1 + (seed % 11)) 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 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. # Mostly solid blocks with a little texture, like a tiny cover.
glyph = "█" if ((seed + column * 7 + row * 11) % 17) not in (0, 5) else "▓" glyph = "█" if ((seed + column * 7 + row * 11) % 17) not in (0, 5) else "▓"
line.append(glyph, style=colour) 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)]
)
+144
View File
@@ -0,0 +1,144 @@
"""Header: the catalogue's hero, the clock and a live ticker."""
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from rich.cells import cell_len
from rich.console import Group, RenderableType
from rich.text import Text
from textual.widget import Widget
from .common import (
active_stops,
clock_time,
date_label,
gradient,
marquee,
state_glyph,
)
if TYPE_CHECKING:
from ..app import BeefyPlayerApp
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):
can_focus = False
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)
# 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 = 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 >= 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(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)
line.append(middle, style="dim")
# Keep the right block on one column even though the middle
# 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 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 - text_offset]
line.append(" " * pad)
line.append(" " * max(0, right_width - cell_len(item)))
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} "
ticker.append(prefix, style=f"bold {colour}")
station = app.current
if app.notice:
text = app.notice
text_style = "italic yellow"
elif station is not None:
song = station.now_playing
if song is not None:
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
)
# 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])
+168
View File
@@ -0,0 +1,168 @@
"""Full-screen keyboard help."""
from __future__ import annotations
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 .. 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"),
("] / [", "next / previous station (tunes immediately)"),
("+ / -", "volume up / down"),
("m", "mute / unmute"),
("f", "favourite — favourites float to the top"),
("/", "filter stations by title, genre or description"),
("esc", "close the search box; esc again clears the filter"),
(
"v",
"cycle visualiser: mirror → bars → wave → waterfall → radial → braille",
),
("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"),
)
class HelpScreen(ModalScreen[None]):
def compose(self) -> ComposeResult:
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")
table.add_column(style="")
for key, description in HELP_ROWS:
table.add_row(key, description)
header = Text()
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")
# 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(""),
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,
)
File renamed without changes.
+416
View File
@@ -0,0 +1,416 @@
"""The flashy bit: an ANSI-colour spectrum analyser / oscilloscope."""
from __future__ import annotations
import math
from collections import deque
from collections.abc import Sequence
from typing import TYPE_CHECKING, cast
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 . import frame
from .common import LEVELS, active_stops, gradient
if TYPE_CHECKING:
from ..app import BeefyPlayerApp
#: One waterfall column per analyser tick; a deque this long covers ~25 s of
#: history at 20 Hz, plenty for even a very wide terminal.
WATERFALL_HISTORY = 512
WATERFALL_LEVELS = " ░▒▓█"
#: Braille dot bit value, indexed by sub-row and sub-column inside a cell.
BRAILLE_BITS = (
(0x01, 0x08),
(0x02, 0x10),
(0x04, 0x20),
(0x40, 0x80),
)
VISUALIZER_LABELS = {
"both": "MIRROR",
"bars": "BARS",
"wave": "WAVE",
"waterfall": "WATERFALL",
"radial": "RADIAL",
"braille": "BRAILLE",
}
class SpectrumPanel(Widget):
can_focus = False
def __init__(self, engine: SpectrumEngine) -> None:
super().__init__()
self.engine = engine
self.border_title = "SPECTRUM"
#: 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)
self._history_tick: int | None = None
# Called from the app ticker so the border title tracks the analyser mode.
def sync(self, mode: str, status: str) -> None:
label = VISUALIZER_LABELS.get(mode, mode.upper())
title = f"SPECTRUM · {label} · {status}"
if self.border_title != title:
self.border_title = title
self.refresh()
def render(self) -> RenderableType:
app = cast("BeefyPlayerApp", self.app)
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 = snapshot.status == "idle"
self._record(snapshot, int(getattr(app, "tick_count", 0)))
lines: list[Text]
if mode == "wave":
lines = self._wave(snapshot, width, height, phase, dim)
elif mode == "braille":
lines = self._braille(snapshot, width, height, phase, dim)
elif mode == "waterfall":
lines = self._waterfall(width, height, dim)
elif mode == "radial":
lines = self._radial(snapshot, width, height, phase, dim)
elif mode == "bars" or height < 8:
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(snapshot, width, top_height, phase, dim)
mirrored = self._bars(
snapshot, width, bottom_height, phase + 5, True, peaks=False
)
mirrored.reverse()
lines.extend(mirrored)
return frame.wrap(
lines, outer_width, outer_height, self.border_title, self._stops
)
# ------------------------------------------------------------------ #
# history
# ------------------------------------------------------------------ #
def _record(self, frame: Frame, tick: int) -> None:
"""Remember one column per analyser tick (not one per render)."""
if tick == self._history_tick:
return
self._history_tick = tick
self._history.append(frame.bands)
# ------------------------------------------------------------------ #
# drawing
# ------------------------------------------------------------------ #
@staticmethod
def _sample(values: Sequence[float], position: float) -> float:
"""Linearly interpolate ``values`` at a fractional index."""
if not values:
return 0.0
last = len(values) - 1
position = max(0.0, min(float(last), position))
low = int(position)
high = min(low + 1, last)
fraction = position - low
return float(values[low]) * (1.0 - fraction) + float(values[high]) * fraction
def _row_palette(self, height: int, phase: int, dim: bool) -> list[str]:
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(stops, position)
style = f"bold {colour}" if (row + phase) % 7 == 0 else colour
if dim:
style = f"dim {style}"
palette.append(style)
if len(self._palettes) > 96:
self._palettes.clear()
self._palettes[key] = palette
return palette
@staticmethod
def _resample(values: Sequence[float], width: int) -> list[float]:
count = len(values)
if count == 0:
return [0.0] * width
if width <= 1 or count == 1:
return [float(values[0])] * width
last = count - 1
output: list[float] = []
for column in range(width):
position = column * last / (width - 1)
low = int(position)
high = min(low + 1, last)
fraction = position - low
output.append(values[low] * (1.0 - fraction) + values[high] * fraction)
return output
@staticmethod
def _resample_mean(values: Sequence[float], width: int) -> list[float]:
count = len(values)
if count == 0:
return [0.0] * width
output: list[float] = []
for column in range(width):
low = int(column * count / width)
high = max(low + 1, int((column + 1) * count / width))
high = min(high, count)
chunk = values[low:high]
output.append(sum(chunk) / len(chunk) if chunk else 0.0)
return output
def _bars(
self,
frame: Frame,
width: int,
height: int,
phase: int,
dim: bool,
peaks: bool = True,
) -> list[Text]:
values = self._resample(frame.bands, width)
peaks = self._resample(frame.peaks, width)
palette = self._row_palette(height, phase, dim)
scale = height * 8.0
lines: list[Text] = []
for row in range(height):
line = Text(no_wrap=True)
row_top = (height - row - 1) * 8.0
style = palette[row]
for column in range(width):
units = min(values[column], 1.0) * scale - row_top
peak_units = min(peaks[column], 1.0) * scale - row_top
if units >= 8.0:
line.append("█", style=style)
elif units > 0.0:
level = max(1, min(7, int(units)))
line.append(LEVELS[level], style=style)
elif (
peaks
and 0.0 <= peak_units < 8.0
and peak_units > units + 1.0
):
line.append("▁", style="dim" if dim else "bold")
else:
line.append(" ", style="")
lines.append(line)
return lines
def _wave_values(self, frame: Frame, width: int) -> list[float]:
"""The waveform resampled to ``width`` points, with display gain.
The engine normalises each chunk, but smoothing and averaging make the
trace sit around the middle. A gentle display gain lets an oscilloscope
use the full height without amplifying silence into noise.
"""
wave = self._resample_mean(frame.wave, width)
peak = max((abs(value) for value in wave), default=0.0)
if peak > 0.02:
gain = min(2.5, 0.9 / peak)
wave = [value * gain for value in wave]
return wave
def _wave(
self, frame: Frame, width: int, height: int, phase: int, dim: bool
) -> list[Text]:
wave = self._wave_values(frame, width)
palette = self._row_palette(height, phase, dim)
centre = (height - 1) / 2.0
amplitude = max(0.75, (height - 1) / 2.0)
grid = [[" " for _ in range(width)] for _ in range(height)]
previous_row: int | None = None
for column, value in enumerate(wave):
clamped = max(-1.0, min(1.0, float(value)))
position = centre - clamped * amplitude
row = max(0, min(height - 1, round(position)))
grid[row][column] = "█"
delta = position - row
if delta > 0.35 and row + 1 < height:
grid[row + 1][column] = "▄"
elif delta < -0.35 and row - 1 >= 0:
grid[row - 1][column] = "▀"
if previous_row is not None and previous_row != row:
low, high = sorted((previous_row, row))
for between in range(low + 1, high):
if grid[between][column] == " ":
grid[between][column] = "│"
previous_row = row
lines: list[Text] = []
for row in range(height):
line = Text(no_wrap=True)
style = palette[row]
for char in grid[row]:
if char == " ":
line.append(" ", style="")
else:
line.append(char, style=style)
lines.append(line)
return lines
def _braille(
self, frame: Frame, width: int, height: int, phase: int, dim: bool
) -> list[Text]:
"""Oscilloscope drawn with braille dots: 2x4 sub-cells per character."""
dots_width = width * 2
dots_height = height * 4
values = self._wave_values(frame, dots_width)
centre = (dots_height - 1) / 2.0
amplitude = max(1.0, (dots_height - 1) / 2.0)
cells = [[0] * width for _ in range(height)]
def set_dot(x: int, y: int) -> None:
cells[y // 4][x // 2] |= BRAILLE_BITS[y % 4][x % 2]
previous: int | None = None
for column, value in enumerate(values):
clamped = max(-1.0, min(1.0, float(value)))
row = max(0, min(dots_height - 1, round(centre - clamped * amplitude)))
set_dot(column, row)
# Join steep segments so the trace stays continuous.
if previous is not None and previous != row:
low, high = sorted((previous, row))
for between in range(low + 1, high):
set_dot(column, between)
previous = row
palette = self._row_palette(height, phase, dim)
lines: list[Text] = []
for row in range(height):
line = Text(no_wrap=True)
style = palette[row]
for bits in cells[row]:
if bits:
line.append(chr(0x2800 | bits), style=style)
else:
line.append(" ", style="")
lines.append(line)
return lines
def _waterfall(self, width: int, height: int, dim: bool) -> list[Text]:
"""Scrolling spectrogram: history left-to-right, frequency bottom-up."""
columns = list(self._history)[-width:]
if not columns:
columns = [()]
last = max(1, len(columns[-1]) - 1)
# Row 0 is the treble end; the bass sits on the bottom row.
positions = [
(height - 1 - row) / max(1, height - 1) * last for row in range(height)
]
offset = width - len(columns)
fade_after = max(3, int(len(columns) * 0.75))
grid = [[" "] * width for _ in range(height)]
styles = [[""] * width for _ in range(height)]
for index, column in enumerate(columns):
if not column:
continue
x = offset + index
age = len(columns) - index
faded = dim or age > fade_after
for row, position in enumerate(positions):
value = self._sample(column, position)
level = int(value * (len(WATERFALL_LEVELS) - 1))
if level <= 0:
continue
style = gradient(self._stops, value)
if faded:
style = f"dim {style}"
elif age == 1:
# The newest column is the leading edge of the scan.
style = f"bold {style}"
grid[row][x] = WATERFALL_LEVELS[level]
styles[row][x] = style
lines: list[Text] = []
for row in range(height):
line = Text(no_wrap=True)
for x in range(width):
char = grid[row][x]
line.append(char, style=styles[row][x] if char != " " else "")
lines.append(line)
return lines
def _radial(
self, frame: Frame, width: int, height: int, phase: int, dim: bool
) -> list[Text]:
"""The spectrum wrapped into a glowing circle around the centre."""
centre_x = (width - 1) / 2.0
centre_y = (height - 1) / 2.0
# Terminal cells are about twice as tall as they are wide, so the
# horizontal radius is twice the vertical one to draw a round circle.
radius_y = min(centre_y, centre_x / 2.0)
if radius_y < 2.5:
return self._bars(frame, width, height, phase, dim)
radius_x = radius_y * 2.0
bands = list(frame.bands)
count = max(1, len(bands))
hole = 0.18
grid = [[" "] * width for _ in range(height)]
styles = [[""] * width for _ in range(height)]
for row in range(height):
dy = (row - centre_y) / radius_y
if abs(dy) > 1.0:
continue
for column in range(width):
dx = (column - centre_x) / radius_x
distance = math.hypot(dx, dy)
if distance > 1.0:
continue
angle = (math.atan2(dy, dx) + math.pi / 2.0) % (2.0 * math.pi)
band = bands[int(angle / (2.0 * math.pi) * count) % count]
edge = hole + max(0.0, min(1.0, float(band))) * (1.0 - hole)
if distance <= hole + 0.02:
# A faint inner ring gives silent stations a focus.
if distance > hole * 0.35:
grid[row][column] = "·"
styles[row][column] = "dim"
continue
if distance > edge:
continue
depth = edge - distance
if depth < 0.06:
grid[row][column] = "█"
styles[row][column] = ("dim " if dim else "") + gradient(
self._stops, edge
)
elif depth < 0.15:
grid[row][column] = "▓"
styles[row][column] = "dim " + gradient(self._stops, edge)
elif depth < 0.25:
grid[row][column] = "▒"
styles[row][column] = "dim"
lines: list[Text] = []
for row in range(height):
line = Text(no_wrap=True)
for column in range(width):
char = grid[row][column]
line.append(char, style=styles[row][column] if char != " " else "")
lines.append(line)
return lines
@@ -7,11 +7,11 @@ from typing import TYPE_CHECKING, cast
from rich.console import Group, RenderableType from rich.console import Group, RenderableType
from rich.text import Text from rich.text import Text
from textual.containers import Horizontal from textual.containers import Horizontal, Vertical
from textual.css.query import NoMatches from textual.css.query import NoMatches
from textual.widget import Widget from textual.widget import Widget
from ..api import Song, Station from ..models import Song, Station
from .art import ArtPane from .art import ArtPane
from .common import ( from .common import (
compact_count, compact_count,
@@ -21,9 +21,10 @@ from .common import (
time_ago, time_ago,
truncate, truncate,
) )
from .frame import FrameBottom, FrameSide, FrameTop
if TYPE_CHECKING: if TYPE_CHECKING:
from ..app import SomaFMApp from ..app import BeefyPlayerApp
class StagePanel(Widget): class StagePanel(Widget):
@@ -36,10 +37,18 @@ class StagePanel(Widget):
self.border_title = "ON AIR" self.border_title = "ON AIR"
def compose(self): def compose(self):
# 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"): with Horizontal(id="stage_top"):
yield ArtPane() yield ArtPane()
yield StageInfo() yield StageInfo()
yield HistoryPane() yield HistoryPane()
yield FrameSide("right", id="stage_frame_right")
yield FrameBottom(id="stage_frame_bottom")
def on_mount(self) -> None: def on_mount(self) -> None:
self.refresh_art() self.refresh_art()
@@ -75,27 +84,30 @@ class StageInfo(Widget):
can_focus = False can_focus = False
def render(self) -> RenderableType: def render(self) -> RenderableType:
app = cast("SomaFMApp", self.app) app = cast("BeefyPlayerApp", self.app)
width = max(8, self.size.width) width = max(8, self.size.width)
height = max(1, self.size.height) height = max(1, self.size.height)
station = app.current station = app.current
if station is None: if station is None:
return Group(*self._splash(width, height)) return Group(*self._splash(app, width, height))
details = self._details(app, station, station.now_playing, width) details = self._details(app, station, station.now_playing, width)
return Group(*details[:height]) return Group(*details[:height])
def _splash(self, width: int, height: int) -> list[Text]: def _splash(
self, app: BeefyPlayerApp, width: int, height: int
) -> list[Text]:
provider = app.provider
lines = [ lines = [
Text("◤ SOMA FM ◢", style="bold magenta"), Text(f"◤ {provider.brand} ◢", style="bold magenta"),
Text("listener-supported radio · san francisco", style="italic dim"), Text(provider.tagline, style="italic dim"),
Text(""), Text(""),
Text("↑ ↓ choose a station", style=""), Text("↑ ↓ choose a station", style=""),
Text("⏎ tune in", style=""), Text("⏎ tune in", style=""),
Text("? all keyboard shortcuts", style=""), Text("? all keyboard shortcuts", style=""),
Text(""), Text(""),
Text("Stations load from somafm.com/channels.json", style="dim"), Text(f"Stations load from {provider.station_source}", style="dim"),
] ]
return [ return [
Text(truncate(line.plain, width), style=line.style) Text(truncate(line.plain, width), style=line.style)
@@ -104,7 +116,7 @@ class StageInfo(Widget):
def _details( def _details(
self, self,
app: SomaFMApp, app: BeefyPlayerApp,
station: Station, station: Station,
song: Song | None, song: Song | None,
width: int, width: int,
@@ -137,14 +149,19 @@ class StageInfo(Widget):
album = Text(no_wrap=True) album = Text(no_wrap=True)
if song.album: if song.album:
album.append(truncate(song.album, max(8, width - 22)), style="dim") album.append(truncate(song.album, max(8, width - 22)), style="dim")
if station.dj: presenter = station.presenter_label
if presenter:
if song.album: if song.album:
album.append(" · ", style="dim") album.append(" · ", style="dim")
album.append(f"dj {truncate(station.dj, 18)}", style="dim") album.append(truncate(presenter, max(8, width - 22)), style="dim")
if song.art_url: # 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") album.append(" ▣", style="cyan")
else: else:
artist = Text("waiting for track data…", style="italic yellow") # No track metadata from this catalogue: keep the line, leave it
# blank rather than filling it with an apology.
artist = Text("")
fallback = station.last_playing or "tuning in" fallback = station.last_playing or "tuning in"
title = Text(marquee(fallback, width), style="bold") title = Text(marquee(fallback, width), style="bold")
album = Text(truncate(station.genre_label, width), style="dim") album = Text(truncate(station.genre_label, width), style="dim")
@@ -188,7 +205,7 @@ class HistoryPane(Widget):
can_focus = False can_focus = False
def render(self) -> RenderableType: def render(self) -> RenderableType:
app = cast("SomaFMApp", self.app) app = cast("BeefyPlayerApp", self.app)
width = max(8, self.size.width) width = max(8, self.size.width)
height = max(1, self.size.height) height = max(1, self.size.height)
station = app.current station = app.current
+175
View File
@@ -0,0 +1,175 @@
"""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 RenderableType
from rich.text import Text
from textual.widget import Widget
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
def __init__(self) -> None:
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 ""
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(3, self.size.height)
stops = active_stops(app)
inner_width, inner_height = frame.inner_size(width, height)
view = app.view
if not view:
if getattr(app, "search_pending", False):
message = "searching the directory…"
else:
message = "no stations match" if app.query else "loading stations…"
return frame.wrap(
[Text(truncate(message, inner_width), style="dim italic")],
width,
height,
self.border_title,
stops,
)
total = len(view)
first = 0
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(inner_height, total)):
index = first + offset
station = view[index]
selected = index == app.index
current = app.current is not None and app.current.id == station.id
playing = current and app.playing_state not in ("stopped", "error")
marker = "▌" if selected else " "
star = "★" if station.favorite else " "
play = (
("‖" if app.playing_state == "paused" else "▶") if current else " "
)
count_text = ""
count_width = 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 = content - cell_len(prefix) - count_width
title = truncate(station.title, max(4, available))
gap = max(0, content - cell_len(prefix) - cell_len(title) - count_width)
if selected:
marker_style = "magenta"
title_style = "bold underline"
count_style = "bold"
elif current:
marker_style = "cyan"
title_style = "cyan"
count_style = "dim"
else:
marker_style = ""
title_style = ""
count_style = "dim"
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 "")
row.append(" ")
row.append(title, style=title_style)
row.append(" " * gap)
if count_text:
row.append(count_text, style=count_style)
used = cell_len(prefix) + cell_len(title) + gap + cell_len(count_text)
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 frame.wrap(lines, width, height, self.border_title, stops)
@@ -12,7 +12,7 @@ from textual.widget import Widget
from .common import compact_count, state_glyph, truncate from .common import compact_count, state_glyph, truncate
if TYPE_CHECKING: if TYPE_CHECKING:
from ..app import SomaFMApp from ..app import BeefyPlayerApp
HINTS_LONG = ( HINTS_LONG = (
"SPACE pause ↑↓ station ENTER tune +/- vol f ★ / filter " "SPACE pause ↑↓ station ENTER tune +/- vol f ★ / filter "
@@ -28,7 +28,7 @@ class StatusBar(Widget):
can_focus = False can_focus = False
def render(self) -> RenderableType: def render(self) -> RenderableType:
app = cast("SomaFMApp", self.app) app = cast("BeefyPlayerApp", self.app)
width = max(20, self.size.width) width = max(20, self.size.width)
glyph, colour = state_glyph(app.playing_state) glyph, colour = state_glyph(app.playing_state)
frame = app.spectrum.snapshot() frame = app.spectrum.snapshot()
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.
+10 -10
View File
@@ -1,13 +1,13 @@
[project] [project]
name = "somafm-tui" name = "beefy-player"
version = "0.1.0" version = "1.2.2"
description = "A flashy terminal radio for SomaFM with a live spectrum analyser, MPRIS / macOS Now Playing support and song history" description = "A flashy terminal player for internet radio — live spectrum analyser, MPRIS / macOS Now Playing support, song history and pluggable station catalogues (SomaFM, Radio Paradise, dublab, Rinse FM and more)"
readme = "README.md" readme = "README.md"
license = "WTFPL" license = "WTFPL"
license-files = ["LICENSE"] license-files = ["LICENSE"]
requires-python = ">=3.10" requires-python = ">=3.10"
authors = [{ name = "somafm-tui contributors" }] authors = [{ name = "beefy-player contributors" }]
keywords = ["somafm", "radio", "tui", "terminal", "mpris", "audio"] keywords = ["radio", "internet-radio", "tui", "terminal", "mpris", "audio", "somafm", "tilderadio", "bassdrive", "radioparadise", "radiobrowser", "dublab"]
classifiers = [ classifiers = [
"Environment :: Console", "Environment :: Console",
"Intended Audience :: End Users/Desktop", "Intended Audience :: End Users/Desktop",
@@ -31,17 +31,17 @@ dependencies = [
] ]
[project.scripts] [project.scripts]
somafm-tui = "somafm_tui.__main__:main" beefy-player = "beefy_player.__main__:main"
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["somafm_tui"] packages = ["beefy_player"]
[tool.hatch.build.targets.wheel.force-include] [tool.hatch.build.targets.wheel.force-include]
"somafm_tui/theme.tcss" = "somafm_tui/theme.tcss" "beefy_player/theme.tcss" = "beefy_player/theme.tcss"
[dependency-groups] [dependency-groups]
dev = [ dev = [
@@ -60,8 +60,8 @@ extend-exclude = ["dist", ".venv", "build"]
[tool.ruff.lint] [tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "I", "UP", "B", "SIM", "RUF"] select = ["E4", "E7", "E9", "F", "I", "UP", "B", "SIM", "RUF"]
ignore = ["RUF001"] # en/em dashes are part of real SomaFM metadata ignore = ["RUF001"] # en/em dashes are part of real station metadata
# dbus-next needs short signature strings ("s", "a{sv}", "x", …) in # dbus-next needs short signature strings ("s", "a{sv}", "x", …) in
# annotations; they are not Python names. # annotations; they are not Python names.
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"somafm_tui/mpris.py" = ["F821", "F722", "UP037"] "beefy_player/mpris.py" = ["F821", "F722", "UP037"]
+7 -7
View File
@@ -1,6 +1,6 @@
{ pkgs ? import <nixpkgs> { } }: { pkgs ? import <nixpkgs> { } }:
# FHS development shell for somafm-tui. # FHS development shell for beefy-player.
# #
# Why FHS? uv installs manylinux wheels from PyPI (notably NumPy) that expect # Why FHS? uv installs manylinux wheels from PyPI (notably NumPy) that expect
# libstdc++.so.6 and friends in the usual FHS locations (/usr/lib, /lib). # libstdc++.so.6 and friends in the usual FHS locations (/usr/lib, /lib).
@@ -12,13 +12,13 @@
# nix-shell # enter the FHS shell # nix-shell # enter the FHS shell
# rm -rf .venv # once, if the venv was created by an older setup # rm -rf .venv # once, if the venv was created by an older setup
# uv sync # install Python deps with uv # uv sync # install Python deps with uv
# uv run somafm-tui # start the radio # uv run beefy-player # start the radio
# #
# Note: `nix-shell --run "..."` is not supported by FHS shell.nix files; enter # Note: `nix-shell --run "..."` is not supported by FHS shell.nix files; enter
# the shell interactively and run commands there. # the shell interactively and run commands there.
(pkgs.buildFHSEnv { (pkgs.buildFHSEnv {
name = "somafm-tui"; name = "beefy-player";
targetPkgs = pkgs: with pkgs; [ targetPkgs = pkgs: with pkgs; [
# --- Python toolchain; uv owns all Python dependencies --------------- # --- Python toolchain; uv owns all Python dependencies ---------------
@@ -83,13 +83,13 @@
export DBUS_SESSION_BUS_ADDRESS="unix:path=$XDG_RUNTIME_DIR/bus" export DBUS_SESSION_BUS_ADDRESS="unix:path=$XDG_RUNTIME_DIR/bus"
fi fi
export PS1='\[\e[1;35m\][somafm-tui]\[\e[0m\] \w \$ ' export PS1='\[\e[1;35m\][beefy-player]\[\e[0m\] \w \$ '
echo "" echo ""
echo " somafm-tui — FHS dev shell" echo " beefy-player — FHS dev shell"
echo " --------------------------" echo " ----------------------------"
echo " uv sync install / update python deps" echo " uv sync install / update python deps"
echo " uv run somafm-tui start the radio" echo " uv run beefy-player start the radio"
echo "" echo ""
echo " python : $(python3 --version 2>&1)" echo " python : $(python3 --version 2>&1)"
echo " uv : $(uv --version 2>&1)" echo " uv : $(uv --version 2>&1)"
-3
View File
@@ -1,3 +0,0 @@
"""A flashy terminal radio player for somafm.com."""
__version__ = "0.1.0"
-463
View File
@@ -1,463 +0,0 @@
"""Async client for the public SomaFM endpoints.
The app talks to three things:
* ``https://somafm.com/channels.json`` -> every station + listener counts
* ``https://somafm.com/<station>.pls`` -> tiny PLS playlists (resolved to ice URLs)
* ``https://somafm.com/songs/<station>.json`` -> the "last played" song history
All parsing is deliberately forgiving: SomaFM has changed field names before
and we would rather show a slightly degraded station than crash.
"""
from __future__ import annotations
import asyncio
import re
import time
from collections.abc import Iterable
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from urllib.parse import quote
import httpx
from . import config
CHANNELS_URL = "https://somafm.com/channels.json"
SONGS_URL = "https://somafm.com/songs/{station}.json"
USER_AGENT = "somafm-tui/0.1 (terminal radio; +https://somafm.com)"
REQUEST_TIMEOUT = httpx.Timeout(15.0, connect=8.0)
PLAYLIST_TTL = 8 * 3600
_QUALITY_RANK = {"highest": 0, "high": 1, "low": 2}
_FORMAT_RANK = {"mp3": 0, "aac": 1, "aacp": 2}
_PLAYLIST_CACHE: dict[str, dict[str, Any]] | None = None
def make_client() -> httpx.AsyncClient:
return httpx.AsyncClient(
headers={"User-Agent": USER_AGENT, "Accept": "application/json, */*"},
timeout=REQUEST_TIMEOUT,
follow_redirects=True,
)
# --------------------------------------------------------------------------- #
# models
# --------------------------------------------------------------------------- #
@dataclass(slots=True)
class Song:
title: str = ""
artist: str = ""
album: str = ""
art_url: str = ""
bandcamp_url: str = ""
played_at: datetime | None = None
@property
def display_title(self) -> str:
return self.title.strip() or "Unknown track"
@property
def display_artist(self) -> str:
return self.artist.strip() or "Unknown artist"
def key(self) -> str:
return f"{self.artist}\x1f{self.title}\x1f{self.album}"
@dataclass(slots=True)
class Station:
id: str
title: str
description: str = ""
genre: str = ""
dj: str = ""
image: str = ""
listeners: int = 0
last_playing: str = ""
playlists: list[dict[str, str]] = field(default_factory=list)
now_playing: Song | None = None
history: list[Song] = field(default_factory=list)
favorite: bool = False
@property
def genre_label(self) -> str:
return self.genre.replace("|", " · ") or "eclectic"
def sorted_playlists(self) -> list[dict[str, str]]:
return sorted(
self.playlists,
key=lambda pl: (
_QUALITY_RANK.get(pl.get("quality", ""), 3),
_FORMAT_RANK.get(pl.get("format", ""), 3),
),
)
@property
def format_label(self) -> str:
playlists = self.sorted_playlists()
if not playlists:
return "stream"
first = playlists[0]
fmt = {"aacp": "aac+", "aac": "aac", "mp3": "mp3"}.get(
first.get("format", ""), first.get("format") or "stream"
)
return f"{fmt} · {_guess_bitrate(first.get('url', ''))}k"
def synthetic_mirrors(self) -> list[str]:
"""Direct icecast URLs, handy when the PLS files are unreachable."""
urls: list[str] = []
for suffix in ("-128-mp3", "-64-aac"):
for host in ("ice1", "ice2", "ice3", "ice4"):
urls.append(f"https://{host}.somafm.com/{self.id}{suffix}")
return urls
# --------------------------------------------------------------------------- #
# small helpers
# --------------------------------------------------------------------------- #
def _guess_bitrate(url: str) -> int:
match = re.search(
r"(256|192|130|128|96|64|48|32)(?=\.pls|\.m3u|-mp3|-aac)", url
)
if not match:
return 128
value = int(match.group(1))
return 128 if value == 130 else value
def _to_int(value: Any, default: int = 0) -> int:
if isinstance(value, bool):
return default
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
if isinstance(value, str):
digits = re.sub(r"[^0-9]", "", value)
if digits:
try:
return int(digits)
except ValueError:
return default
return default
def parse_date(value: Any) -> datetime | None:
if value is None or value == "":
return None
if isinstance(value, (int, float)):
try:
return datetime.fromtimestamp(float(value), tz=timezone.utc)
except (OverflowError, OSError, ValueError):
return None
if isinstance(value, str):
text = value.strip()
if text.isdigit():
try:
return datetime.fromtimestamp(int(text), tz=timezone.utc)
except (OverflowError, OSError, ValueError):
return None
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
parsed = None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y/%m/%d %H:%M:%S"):
try:
parsed = datetime.strptime(text, fmt)
break
except ValueError:
continue
if parsed is not None:
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
return None
def parse_last_playing(text: str | None) -> Song | None:
if not isinstance(text, str):
return None
cleaned = text.strip()
if not cleaned:
return None
for separator in (" - ", " – ", " — ", " -- "):
if separator in cleaned:
artist, title = cleaned.split(separator, 1)
return Song(title=title.strip(), artist=artist.strip())
return Song(title=cleaned)
# --------------------------------------------------------------------------- #
# channels
# --------------------------------------------------------------------------- #
def station_from_payload(item: dict[str, Any]) -> Station | None:
if not isinstance(item, dict):
return None
channel_id = item.get("id")
if not isinstance(channel_id, str) or not channel_id.strip():
return None
channel_id = channel_id.strip()
playlists: list[dict[str, str]] = []
raw_playlists = item.get("playlists")
if isinstance(raw_playlists, list):
for entry in raw_playlists:
if not isinstance(entry, dict):
continue
url = entry.get("url")
if not isinstance(url, str) or not url.startswith(("http://", "https://")):
continue
playlists.append(
{
"url": url,
"format": str(entry.get("format") or "").strip().lower(),
"quality": str(entry.get("quality") or "").strip().lower(),
}
)
station = Station(
id=channel_id,
title=str(item.get("title") or channel_id).strip(),
description=str(item.get("description") or "").strip(),
genre=str(item.get("genre") or "").strip(),
dj=str(item.get("dj") or "").strip(),
image=str(item.get("image") or item.get("largeimage") or "").strip(),
listeners=_to_int(item.get("listeners")),
playlists=playlists,
)
last_playing = item.get("lastPlaying")
if isinstance(last_playing, str):
station.last_playing = last_playing.strip()
station.now_playing = parse_last_playing(station.last_playing)
if not station.image:
station.image = f"https://somafm.com/logos/256/{channel_id}256.png"
return station
def parse_channels(payload: Any) -> list[Station]:
if isinstance(payload, dict):
items = payload.get("channels")
elif isinstance(payload, list):
items = payload
else:
items = None
if not isinstance(items, list):
return []
stations: list[Station] = []
seen: set[str] = set()
for item in items:
station = station_from_payload(item)
if station is None or station.id in seen:
continue
seen.add(station.id)
stations.append(station)
return stations
async def fetch_channels(client: httpx.AsyncClient) -> list[Station]:
payload: Any = None
last_error: Exception | None = None
for attempt in range(2):
try:
response = await client.get(CHANNELS_URL)
response.raise_for_status()
payload = response.json()
break
except (httpx.HTTPError, ValueError) as exc: # includes JSON errors
last_error = exc
if attempt == 0:
await asyncio.sleep(0.6)
if payload is None:
raise RuntimeError(f"could not fetch SomaFM channels: {last_error}")
stations = parse_channels(payload)
if not stations:
raise RuntimeError("SomaFM returned an empty channel list")
config.write_json(config.CHANNELS_CACHE, payload)
return stations
def load_cached_channels() -> list[Station]:
return parse_channels(config.read_json(config.CHANNELS_CACHE))
# --------------------------------------------------------------------------- #
# song history
# --------------------------------------------------------------------------- #
def parse_songs(payload: Any) -> list[Song]:
if isinstance(payload, dict):
items = payload.get("songs")
elif isinstance(payload, list):
items = payload
else:
items = None
if not isinstance(items, list):
return []
songs: list[Song] = []
for entry in items:
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("albumArt") or entry.get("albumart") or entry.get("art") or ""
).strip(),
played_at=parse_date(entry.get("date")),
)
if song.title or song.artist:
songs.append(song)
# Newest first; the upstream feed is already sorted but let's be sure.
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_cache_path(station_id: str):
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", station_id)
return config.SONGS_CACHE_DIR / f"{safe}.json"
def load_cached_songs(station_id: str) -> list[Song]:
raw = config.read_json(_songs_cache_path(station_id))
if isinstance(raw, dict):
return parse_songs(raw.get("songs"))
return []
def _songs_to_cache(songs: Iterable[Song]) -> list[dict[str, Any]]:
return [
{
"title": song.title,
"artist": song.artist,
"album": song.album,
"albumArt": song.art_url,
"date": song.played_at.isoformat() if song.played_at else "",
}
for song in songs
]
async def fetch_history(client: httpx.AsyncClient, station_id: str) -> list[Song]:
url = SONGS_URL.format(station=quote(station_id, safe=""))
try:
response = await client.get(url)
response.raise_for_status()
payload = response.json()
except (httpx.HTTPError, ValueError):
return load_cached_songs(station_id)
songs = parse_songs(payload)
if songs:
config.write_json(
_songs_cache_path(station_id),
{"fetched": time.time(), "songs": _songs_to_cache(songs)},
)
return songs
return load_cached_songs(station_id)
# --------------------------------------------------------------------------- #
# playlist resolution
# --------------------------------------------------------------------------- #
def is_playlist(url: str) -> bool:
path = url.split("?", 1)[0].split("#", 1)[0].lower()
return path.endswith((".pls", ".m3u", ".m3u8"))
def is_direct_stream(url: str) -> bool:
return url.startswith(("http://", "https://")) and not is_playlist(url)
def parse_pls(text: str) -> list[str]:
urls: list[str] = []
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
match = re.match(r"(?i)^file\d+\s*=\s*(\S+)$", line)
candidate = match.group(1) if match else line
if candidate.startswith(("http://", "https://")) and candidate not in urls:
urls.append(candidate)
return urls
def _playlist_cache() -> dict[str, dict[str, Any]]:
global _PLAYLIST_CACHE
if _PLAYLIST_CACHE is None:
raw = config.read_json(config.PLAYLIST_CACHE)
_PLAYLIST_CACHE = raw if isinstance(raw, dict) else {}
return _PLAYLIST_CACHE
async def resolve_playlist(client: httpx.AsyncClient, url: str) -> list[str]:
"""Turn a ``.pls`` URL into direct icecast URLs (cached for a few hours)."""
if not url:
return []
if not is_playlist(url):
return [url]
cache = _playlist_cache()
now = time.time()
entry = cache.get(url)
if isinstance(entry, dict):
fetched = entry.get("ts")
urls = entry.get("urls")
if (
isinstance(fetched, (int, float))
and now - fetched < PLAYLIST_TTL
and isinstance(urls, list)
):
cached_urls = [item for item in urls if isinstance(item, str)]
if cached_urls:
return cached_urls
try:
response = await client.get(url, headers={"Accept": "*/*"})
response.raise_for_status()
text = response.text
except httpx.HTTPError:
return []
urls = parse_pls(text)
if urls:
cache[url] = {"ts": now, "urls": urls}
config.write_json(config.PLAYLIST_CACHE, cache)
return urls
async def stream_candidates(client: httpx.AsyncClient, station: Station) -> list[str]:
"""Direct URLs first, then unresolved playlists, then guessed mirrors."""
playlists = station.sorted_playlists()
urls: list[str] = []
for entry in playlists[:3]:
resolved = await resolve_playlist(client, entry["url"])
for url in resolved:
if url not in urls:
urls.append(url)
if not resolved and entry["url"] not in urls:
urls.append(entry["url"])
for entry in playlists[3:]:
if entry["url"] not in urls:
urls.append(entry["url"])
for url in station.synthetic_mirrors():
if url not in urls:
urls.append(url)
return urls[:10]
-126
View File
@@ -1,126 +0,0 @@
"""Paths, persistent settings and tiny cache helpers.
Everything lives in the usual XDG places:
~/.config/somafm-tui/config.json favourites, volume, last station
~/.cache/somafm-tui/channels.json last good copy of somafm.com/channels.json
~/.cache/somafm-tui/playlists.json resolved .pls -> stream URLs
~/.cache/somafm-tui/songs/<id>.json last good copy of the song history
"""
from __future__ import annotations
import contextlib
import json
import os
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
APP_NAME = "somafm-tui"
def _xdg(env_var: str, fallback: str) -> Path:
value = os.environ.get(env_var)
if value:
return Path(value).expanduser()
return Path.home() / fallback
CONFIG_DIR = _xdg("XDG_CONFIG_HOME", ".config") / APP_NAME
CACHE_DIR = _xdg("XDG_CACHE_HOME", ".cache") / APP_NAME
RUNTIME_DIR = Path(os.environ.get("XDG_RUNTIME_DIR") or "/tmp") / APP_NAME
CONFIG_PATH = CONFIG_DIR / "config.json"
CHANNELS_CACHE = CACHE_DIR / "channels.json"
PLAYLIST_CACHE = CACHE_DIR / "playlists.json"
SONGS_CACHE_DIR = CACHE_DIR / "songs"
VISUALIZERS = ("both", "bars", "wave")
def ensure_dirs() -> None:
for directory in (CONFIG_DIR, CACHE_DIR, SONGS_CACHE_DIR):
with contextlib.suppress(OSError):
directory.mkdir(parents=True, exist_ok=True)
def atomic_write_text(path: Path, text: str) -> None:
"""Write ``text`` to ``path`` without ever leaving a half-written file."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(f".{path.name}.tmp")
tmp.write_text(text, encoding="utf-8")
os.replace(tmp, path)
def read_json(path: Path) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
def write_json(path: Path, payload: Any) -> None:
with contextlib.suppress(OSError):
atomic_write_text(path, json.dumps(payload, indent=2, ensure_ascii=False))
@dataclass(slots=True)
class Config:
favorites: list[str] = field(default_factory=list)
last_station: str = "groovesalad"
volume: int = 70
visualizer: str = "both"
autoplay: bool = False
#: Publish what is playing to the desktop media session (MPRIS on Linux,
#: macOS Now Playing via mpv) and honour the media keys. The JSON key is
#: spelled ``mpris`` for backwards compatibility.
mpris: bool = True
@classmethod
def load(cls) -> Config:
raw = read_json(CONFIG_PATH)
config = cls()
if not isinstance(raw, dict):
return config
favorites = raw.get("favorites")
if isinstance(favorites, list):
config.favorites = [str(item) for item in favorites if isinstance(item, str)]
last_station = raw.get("last_station")
if isinstance(last_station, str) and last_station:
config.last_station = last_station
volume = raw.get("volume")
if isinstance(volume, (int, float)):
config.volume = max(0, min(100, int(volume)))
visualizer = raw.get("visualizer")
if visualizer in VISUALIZERS:
config.visualizer = visualizer
if isinstance(raw.get("autoplay"), bool):
config.autoplay = raw["autoplay"]
if isinstance(raw.get("mpris"), bool):
config.mpris = raw["mpris"]
return config
def save(self) -> None:
try:
ensure_dirs()
atomic_write_text(
CONFIG_PATH, json.dumps(asdict(self), indent=2, ensure_ascii=False)
)
except OSError:
pass
def is_favorite(self, station_id: str) -> bool:
return station_id in self.favorites
def toggle_favorite(self, station_id: str) -> bool:
if station_id in self.favorites:
self.favorites.remove(station_id)
return False
self.favorites.append(station_id)
return True
-1
View File
@@ -1 +0,0 @@
"""Custom Textual widgets for somafm-tui."""
-107
View File
@@ -1,107 +0,0 @@
"""Header: logo, clock and a live ticker."""
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from rich.cells import cell_len
from rich.console import Group, RenderableType
from rich.text import Text
from textual.widget import Widget
from .common import (
SPECTRUM_STOPS,
clock_time,
date_label,
gradient,
marquee,
state_glyph,
)
if TYPE_CHECKING:
from ..app import SomaFMApp
LOGO = (
"▄▀▀▀ ▄▀▀▄ █▄ ▄█ ▄▀▀▄",
"▀▀▀▄ █ █ █ █ █ █▀▀█",
"▄▄▄▀ ▀▄▄▀ █ █ █ █",
)
MIDDLE = ("SOMA FM RADIO", "LISTENER SUPPORTED", "SAN FRANCISCO")
class HeaderBar(Widget):
can_focus = False
def render(self) -> RenderableType:
app = cast("SomaFMApp", self.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)
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
if show_middle and show_right:
show_middle = width >= logo_width + 4 + middle_width + 2 + right_width
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:
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").
line.append(" " * max(0, middle_width - cell_len(middle)))
if show_right:
used = logo_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]
line.append(" " * pad)
line.append(" " * max(0, right_width - cell_len(item)))
style = "bold" if index == 0 else "dim"
line.append(item, style=style)
lines.append(line)
if height >= 4:
ticker = Text(no_wrap=True)
prefix = f"{glyph} {state.upper():<8} "
ticker.append(prefix, style=f"bold {colour}")
station = app.current
if app.notice:
text = app.notice
text_style = "italic yellow"
elif station is not None:
song = station.now_playing
if song is not None:
text = f"{station.title} · {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)
return Group(*lines[:height])
-94
View File
@@ -1,94 +0,0 @@
"""Full-screen keyboard help."""
from __future__ import annotations
import sys
import textwrap
from rich.console import Group
from rich.table import Table
from rich.text import Text
from textual.app import ComposeResult
from textual.screen import ModalScreen
from textual.widgets import Static
from .common import SPECTRUM_STOPS, gradient
HELP_ROWS: tuple[tuple[str, str], ...] = (
("↑ / ↓ j / k", "move through the station list"),
("enter", "tune in to the selected station"),
("space", "play / pause the current station"),
("s", "stop playback"),
("] / [", "next / previous station (tunes immediately)"),
("+ / -", "volume up / down"),
("m", "mute / unmute"),
("f", "favourite — favourites float to the top"),
("/", "filter stations by title, genre or description"),
("esc", "clear the filter / close overlays"),
("v", "cycle visualiser: mirrored bars → bars → wave"),
("r", "refresh station list and song history"),
("g / G", "jump to the top / bottom of the list"),
("? ", "this help"),
("q", "quit"),
)
ABOUT_COMMON = (
(
"Stations and song history come from somafm.com "
"(channels.json + songs/<station>.json)."
),
)
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 "
"somafmtui 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."
),
)
ABOUT = ABOUT_COMMON + (ABOUT_MACOS if sys.platform == "darwin" else ABOUT_LINUX)
class HelpScreen(ModalScreen[None]):
def compose(self) -> ComposeResult:
table = Table.grid(padding=(0, 2), expand=False)
table.add_column(style="bold cyan", no_wrap=True, justify="right")
table.add_column(style="")
for key, description in HELP_ROWS:
table.add_row(key, description)
header = Text()
header.append("◤ ", style=f"bold {gradient(SPECTRUM_STOPS, 0.9)}")
header.append("SOMAFM TUI", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}")
header.append(" keyboard & credits", style="dim")
footer = Text()
for paragraph in ABOUT:
for line in textwrap.wrap(paragraph, width=70):
footer.append(line + "\n", style="dim")
body = Group(header, Text(""), table, Text(""), footer)
yield Static(body, id="help")
def on_key(self, event) -> None:
event.stop()
self.dismiss(None)
-202
View File
@@ -1,202 +0,0 @@
"""The flashy bit: an ANSI-colour spectrum analyser / oscilloscope."""
from __future__ import annotations
from collections.abc import Sequence
from typing import TYPE_CHECKING, cast
from rich.console import Group, RenderableType
from rich.text import Text
from textual.widget import Widget
from ..spectrum import Frame, SpectrumEngine
from .common import LEVELS, SPECTRUM_STOPS, gradient
if TYPE_CHECKING:
from ..app import SomaFMApp
class SpectrumPanel(Widget):
can_focus = False
def __init__(self, engine: SpectrumEngine) -> None:
super().__init__()
self.engine = engine
self.border_title = "SPECTRUM"
self._palettes: dict[tuple[int, int, bool], list[str]] = {}
# Called from the app ticker so the border title tracks the analyser mode.
def sync(self, mode: str, status: str) -> None:
label = {"both": "MIRROR", "bars": "BARS", "wave": "WAVE"}.get(
mode, mode.upper()
)
title = f"SPECTRUM · {label} · {status}"
if self.border_title != title:
self.border_title = title
self.refresh()
def render(self) -> RenderableType:
app = cast("SomaFMApp", self.app)
frame = self.engine.snapshot()
width = max(8, self.size.width)
height = max(2, self.size.height)
mode = getattr(app, "visualizer", "both")
phase = int(getattr(app, "tick_count", 0) // 2)
dim = frame.status == "idle"
lines: list[Text]
if mode == "wave":
lines = self._wave(frame, width, height, phase, dim)
elif mode == "bars" or height < 8:
lines = self._bars(frame, 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)
mirrored = self._bars(
frame, width, bottom_height, phase + 5, True, peaks=False
)
mirrored.reverse()
lines.extend(mirrored)
return Group(*lines)
# ------------------------------------------------------------------ #
# drawing
# ------------------------------------------------------------------ #
def _row_palette(self, height: int, phase: int, dim: bool) -> list[str]:
key = (height, phase % 12, dim)
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)
style = f"bold {colour}" if (row + phase) % 7 == 0 else colour
if dim:
style = f"dim {style}"
palette.append(style)
if len(self._palettes) > 96:
self._palettes.clear()
self._palettes[key] = palette
return palette
@staticmethod
def _resample(values: Sequence[float], width: int) -> list[float]:
count = len(values)
if count == 0:
return [0.0] * width
if width <= 1 or count == 1:
return [float(values[0])] * width
last = count - 1
output: list[float] = []
for column in range(width):
position = column * last / (width - 1)
low = int(position)
high = min(low + 1, last)
fraction = position - low
output.append(values[low] * (1.0 - fraction) + values[high] * fraction)
return output
@staticmethod
def _resample_mean(values: Sequence[float], width: int) -> list[float]:
count = len(values)
if count == 0:
return [0.0] * width
output: list[float] = []
for column in range(width):
low = int(column * count / width)
high = max(low + 1, int((column + 1) * count / width))
high = min(high, count)
chunk = values[low:high]
output.append(sum(chunk) / len(chunk) if chunk else 0.0)
return output
def _bars(
self,
frame: Frame,
width: int,
height: int,
phase: int,
dim: bool,
peaks: bool = True,
) -> list[Text]:
values = self._resample(frame.bands, width)
peaks = self._resample(frame.peaks, width)
palette = self._row_palette(height, phase, dim)
scale = height * 8.0
lines: list[Text] = []
for row in range(height):
line = Text(no_wrap=True)
row_top = (height - row - 1) * 8.0
style = palette[row]
for column in range(width):
units = min(values[column], 1.0) * scale - row_top
peak_units = min(peaks[column], 1.0) * scale - row_top
if units >= 8.0:
line.append("█", style=style)
elif units > 0.0:
level = max(1, min(7, int(units)))
line.append(LEVELS[level], style=style)
elif (
peaks
and 0.0 <= peak_units < 8.0
and peak_units > units + 1.0
):
line.append("▁", style="dim" if dim else "bold")
else:
line.append(" ", style="")
lines.append(line)
return lines
def _wave(
self, frame: Frame, width: int, height: int, phase: int, dim: bool
) -> list[Text]:
wave = self._resample_mean(frame.wave, width)
# The engine normalises each chunk, but smoothing and averaging make
# the trace sit around the middle. A gentle display gain lets the
# oscilloscope use the full height without amplifying silence into
# noise.
peak = max((abs(value) for value in wave), default=0.0)
if peak > 0.02:
gain = min(2.5, 0.9 / peak)
wave = [value * gain for value in wave]
palette = self._row_palette(height, phase, dim)
centre = (height - 1) / 2.0
amplitude = max(0.75, (height - 1) / 2.0)
grid = [[" " for _ in range(width)] for _ in range(height)]
previous_row: int | None = None
for column, value in enumerate(wave):
clamped = max(-1.0, min(1.0, float(value)))
position = centre - clamped * amplitude
row = max(0, min(height - 1, round(position)))
grid[row][column] = "█"
delta = position - row
if delta > 0.35 and row + 1 < height:
grid[row + 1][column] = "▄"
elif delta < -0.35 and row - 1 >= 0:
grid[row - 1][column] = "▀"
if previous_row is not None and previous_row != row:
low, high = sorted((previous_row, row))
for between in range(low + 1, high):
if grid[between][column] == " ":
grid[between][column] = "│"
previous_row = row
lines: list[Text] = []
for row in range(height):
line = Text(no_wrap=True)
style = palette[row]
for char in grid[row]:
if char == " ":
line.append(" ", style="")
else:
line.append(char, style=style)
lines.append(line)
return lines
-106
View File
@@ -1,106 +0,0 @@
"""Station list with favourites, live markers and a filter."""
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from rich.cells import cell_len
from rich.console import Group, RenderableType
from rich.text import Text
from textual.widget import Widget
from .common import compact_count, truncate
if TYPE_CHECKING:
from ..app import SomaFMApp
class StationList(Widget):
can_focus = False
def __init__(self) -> None:
super().__init__()
self.border_title = "STATIONS"
def set_filter(self, query: str) -> None:
safe_query = query.replace("[", "(").replace("]", ")") if query else ""
title = f"STATIONS · /{safe_query}" if safe_query else "STATIONS"
if self.border_title != title:
self.border_title = title
def on_mouse_scroll_down(self, event) -> None:
cast("SomaFMApp", self.app).action_cursor_down()
def on_mouse_scroll_up(self, event) -> None:
cast("SomaFMApp", self.app).action_cursor_up()
def render(self) -> RenderableType:
app = cast("SomaFMApp", self.app)
width = max(12, self.size.width)
height = max(1, self.size.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"))
first = 0
if len(view) > height:
first = max(0, min(app.index - height // 2, len(view) - height))
lines: list[Text] = []
for offset in range(min(height, len(view))):
index = first + offset
station = view[index]
selected = index == app.index
current = app.current is not None and app.current.id == station.id
playing = current and app.playing_state not in ("stopped", "error")
marker = "▌" if selected else " "
star = "★" if station.favorite else " "
play = (
("‖" if app.playing_state == "paused" else "▶") if current else " "
)
count_text = ""
count_width = 0
if width >= 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
title = truncate(station.title, max(4, available))
gap = max(0, width - 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.append(marker, style=marker_style)
row.append(star, style="bold yellow" if station.favorite else "")
row.append(play, style="cyan" if playing else "")
row.append(" ")
row.append(title, style=title_style)
row.append(" " * gap)
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))
lines.append(row)
return Group(*lines)
+6 -3
View File
@@ -2,11 +2,12 @@ from __future__ import annotations
import os import os
os.environ["SOMAFM_IMAGES"] = "0" os.environ["BEEFY_IMAGES"] = "0"
import pytest import pytest
from somafm_tui import api, config from beefy_player import config
from beefy_player.providers import base
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -25,6 +26,8 @@ def isolated_state(tmp_path, monkeypatch):
("CONFIG_PATH", config_dir / "config.json"), ("CONFIG_PATH", config_dir / "config.json"),
("CHANNELS_CACHE", cache_dir / "channels.json"), ("CHANNELS_CACHE", cache_dir / "channels.json"),
("PLAYLIST_CACHE", cache_dir / "playlists.json"), ("PLAYLIST_CACHE", cache_dir / "playlists.json"),
("ARTWORK_CACHE_DIR", cache_dir / "artwork"),
("LEGACY_CONFIG_PATH", tmp_path / "legacy" / "config.json"),
): ):
monkeypatch.setattr(config, attr, value) monkeypatch.setattr(config, attr, value)
@@ -33,6 +36,6 @@ def isolated_state(tmp_path, monkeypatch):
path.mkdir(parents=True, exist_ok=True) path.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(config, "ensure_dirs", ensure_dirs) monkeypatch.setattr(config, "ensure_dirs", ensure_dirs)
monkeypatch.setattr(api, "_PLAYLIST_CACHE", None) base.reset_playlist_cache()
ensure_dirs() ensure_dirs()
yield yield
+21 -13
View File
@@ -3,7 +3,12 @@ from __future__ import annotations
import httpx import httpx
import pytest import pytest
from somafm_tui import api, config from beefy_player import config
from beefy_player.models import parse_last_playing
from beefy_player.providers import base, get_provider
from beefy_player.providers.somafm import api
SOMAFM = get_provider("somafm")
CHANNELS_PAYLOAD = { CHANNELS_PAYLOAD = {
"channels": [ "channels": [
@@ -50,14 +55,14 @@ def test_parse_channels() -> None:
def test_parse_last_playing() -> None: def test_parse_last_playing() -> None:
song = api.parse_last_playing("Jens Buchert - Rauschebart") song = parse_last_playing("Jens Buchert - Rauschebart")
assert song is not None and song.artist == "Jens Buchert" and song.title == "Rauschebart" assert song is not None and song.artist == "Jens Buchert" and song.title == "Rauschebart"
en_dash = api.parse_last_playing("Tosca – Amber November") en_dash = parse_last_playing("Tosca – Amber November")
assert en_dash is not None and en_dash.artist == "Tosca" assert en_dash is not None and en_dash.artist == "Tosca"
plain = api.parse_last_playing("Just A Title") plain = parse_last_playing("Just A Title")
assert plain is not None and plain.title == "Just A Title" and plain.artist == "" assert plain is not None and plain.title == "Just A Title" and plain.artist == ""
assert api.parse_last_playing("") is None assert parse_last_playing("") is None
assert api.parse_last_playing(None) is None assert parse_last_playing(None) is None
def test_parse_pls() -> None: def test_parse_pls() -> None:
@@ -67,16 +72,19 @@ File1=https://ice2.somafm.com/groovesalad-256-mp3
File2=https://ice6.somafm.com/groovesalad-256-mp3 File2=https://ice6.somafm.com/groovesalad-256-mp3
Version=2 Version=2
""" """
assert api.parse_pls(text) == [ assert base.parse_pls(text) == [
"https://ice2.somafm.com/groovesalad-256-mp3", "https://ice2.somafm.com/groovesalad-256-mp3",
"https://ice6.somafm.com/groovesalad-256-mp3", "https://ice6.somafm.com/groovesalad-256-mp3",
] ]
assert api.parse_pls("https://ice1.somafm.com/x-128-mp3\n# comment\n") == [ assert base.parse_pls("https://ice1.somafm.com/x-128-mp3\n# comment\n") == [
"https://ice1.somafm.com/x-128-mp3" "https://ice1.somafm.com/x-128-mp3"
] ]
assert api.is_playlist("https://x/y.pls?foo=1") is True assert base.is_playlist("https://x/y.pls?foo=1") is True
assert api.is_direct_stream("https://ice1.somafm.com/x-128-mp3") is True assert base.is_direct_stream("https://ice1.somafm.com/x-128-mp3") is True
assert api.is_direct_stream("https://x/y.m3u") is False assert base.is_direct_stream("https://x/y.m3u") is False
# HLS is the player's job, not ours: mpv reads the playlist itself
assert base.is_playlist("https://x/live/index.m3u8") is False
assert base.is_direct_stream("https://x/live/index.m3u8") is True
def test_parse_songs_and_dates() -> None: def test_parse_songs_and_dates() -> None:
@@ -143,7 +151,7 @@ async def test_stream_candidates_with_mock_transport() -> None:
station = api.parse_channels(CHANNELS_PAYLOAD)[0] station = api.parse_channels(CHANNELS_PAYLOAD)[0]
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
urls = await api.stream_candidates(client, station) urls = await SOMAFM.stream_candidates(client, station)
assert urls[:2] == [ assert urls[:2] == [
"https://ice2.somafm.com/x-256-mp3", "https://ice2.somafm.com/x-256-mp3",
@@ -152,7 +160,7 @@ async def test_stream_candidates_with_mock_transport() -> None:
assert len(requests) == 3 # three playlist URLs, cached by URL assert len(requests) == 3 # three playlist URLs, cached by URL
# second run hits the playlist cache # second run hits the playlist cache
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
await api.stream_candidates(client, station) await SOMAFM.stream_candidates(client, station)
assert len(requests) == 3 assert len(requests) == 3
+459 -15
View File
@@ -6,21 +6,33 @@ from datetime import datetime, timedelta, timezone
import pytest import pytest
from somafm_tui.api import Song from beefy_player import config
from somafm_tui.app import SomaFMApp from beefy_player.app import BeefyPlayerApp
from somafm_tui.widgets.help import HelpScreen from beefy_player.models import Song, Station
from somafm_tui.widgets.stage import StagePanel from beefy_player.providers.base import StationPage
from somafm_tui.widgets.station_list import StationList 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
def rendered_text(app: SomaFMApp) -> str: def _history_method(func):
"""Adapt a ``(client, station_id)`` fake to the provider method signature."""
async def method(self, client, station_id):
return await func(client, station_id)
return method
def rendered_text(app: BeefyPlayerApp) -> str:
svg = html.unescape(app.export_screenshot()).replace("\xa0", " ") svg = html.unescape(app.export_screenshot()).replace("\xa0", " ")
return re.sub(r"<[^>]+>", " ", svg) return re.sub(r"<[^>]+>", " ", svg)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_boot_navigation_filter_favourite_visualizer_help() -> None: async def test_boot_navigation_filter_favourite_visualizer_help() -> None:
app = SomaFMApp(autoplay=False, offline=True, audio=False) app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(110, 36)) as pilot: async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause() await pilot.pause()
@@ -59,8 +71,10 @@ async def test_boot_navigation_filter_favourite_visualizer_help() -> None:
await pilot.pause() await pilot.pause()
assert app.current is not None assert app.current is not None
assert app.current.id == "defcon" assert app.current.id == "defcon"
assert app.query == "" assert app.query == "defcon"
assert len(app.view) == len(app.stations) assert [station.id for station in app.view] == ["defcon"]
assert not search.has_class("visible")
assert app.focused is None
assert app.playing_state in ("stopped", "error") assert app.playing_state in ("stopped", "error")
app.action_favorite() app.action_favorite()
@@ -70,11 +84,11 @@ async def test_boot_navigation_filter_favourite_visualizer_help() -> None:
assert app.index == 0 assert app.index == 0
modes = set() modes = set()
for _ in range(3): for _ in range(len(config.VISUALIZERS)):
await pilot.press("v") await pilot.press("v")
await pilot.pause() await pilot.pause()
modes.add(app.visualizer) modes.add(app.visualizer)
assert modes == {"both", "bars", "wave"} assert modes == set(config.VISUALIZERS)
await pilot.press("question_mark") await pilot.press("question_mark")
await pilot.pause() await pilot.pause()
@@ -89,9 +103,317 @@ async def test_boot_navigation_filter_favourite_visualizer_help() -> None:
assert "DEF CON" in text.upper() assert "DEF CON" in text.upper()
@pytest.mark.asyncio
async def test_escape_leaves_the_filter_sticky_and_a_second_escape_clears_it() -> None:
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
await pilot.press("slash")
for char in "defcon":
await pilot.press(char)
await pilot.pause()
search = app.query_one("#search")
assert search.has_class("visible")
assert app.query == "defcon"
assert [station.id for station in app.view] == ["defcon"]
await pilot.press("escape")
await pilot.pause()
assert app.query == "defcon"
assert search.value == "defcon"
assert not search.has_class("visible")
assert app.focused is None
assert [station.id for station in app.view] == ["defcon"]
assert "defcon" in app.query_one(StationList).border_title
app.action_favorite()
await pilot.pause()
assert "defcon" in app.config.favorites
assert app.query == "defcon"
assert [station.id for station in app.view] == ["defcon"]
await pilot.press("escape")
await pilot.pause()
assert app.query == ""
assert search.value == ""
assert len(app.view) == len(app.stations)
@pytest.mark.asyncio
async def test_enter_keeps_the_filter_sticky_like_the_first_escape() -> None:
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
await pilot.press("slash")
for char in "defcon":
await pilot.press(char)
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
search = app.query_one("#search")
assert app.current is not None
assert app.current.id == "defcon"
assert app.query == "defcon"
assert [station.id for station in app.view] == ["defcon"]
assert not search.has_class("visible")
await pilot.press("escape")
await pilot.pause()
assert app.query == ""
assert len(app.view) == len(app.stations)
@pytest.mark.asyncio
async def test_directory_search_uses_the_server_not_the_loaded_list(monkeypatch) -> None:
app = BeefyPlayerApp(
provider="radiobrowser", autoplay=False, offline=True, audio=False
)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
app.offline = False
found = Station(
id="deep-jazz",
title="Deep Jazz",
description="France · french",
genre="jazz",
provider="radiobrowser",
)
queries: list[str] = []
async def fake_search(self, client, query):
queries.append(query)
return [found]
monkeypatch.setattr(type(app.provider), "search_stations", fake_search)
app.query = "jazz"
app._rebuild_view()
assert not any(station.id == "deep-jazz" for station in app.view)
worker = app._search_directory("jazz")
await worker.wait()
await pilot.pause()
assert queries == ["jazz"]
assert [station.id for station in app.view] == ["deep-jazz"]
assert app._find_station("deep-jazz") is found
app._tune(found)
assert any(station.id == "deep-jazz" for station in app.stations)
app._set_stations(
[Station(id="other", title="Other", provider="radiobrowser")]
)
assert app.current.id == "deep-jazz"
assert any(station.id == "deep-jazz" for station in app.stations)
app.query = ""
app.search_results = None
app._search_results_query = ""
app._rebuild_view()
assert len(app.view) == len(app.stations)
@pytest.mark.asyncio
async def test_a_refresh_replaces_a_fallback_station() -> None:
app = BeefyPlayerApp(
provider="radiobrowser", autoplay=False, offline=True, audio=False
)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
assert app.current is not None
assert app.current.id.startswith("fallback-")
app._set_stations(
[Station(id="real", title="Real Station", provider="radiobrowser")]
)
assert app.current.id == "real"
assert [station.id for station in app.stations] == ["real"]
@pytest.mark.asyncio
async def test_favoriting_a_loaded_station_from_search_updates_it_in_place() -> None:
app = BeefyPlayerApp(
provider="radiobrowser", autoplay=False, offline=True, audio=False
)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
existing = app.stations[0]
search_copy = Station(
id=existing.id, title=existing.title, provider="radiobrowser"
)
app.query = "jazz"
app._set_search_results("jazz", [search_copy])
assert app.view[0] is existing
app.index = 0
app.action_favorite()
await pilot.pause()
assert existing.favorite is True
assert app.config.is_favorite(existing.id)
app._close_search(clear=True)
await pilot.pause()
assert app.view[0].id == existing.id
@pytest.mark.asyncio
async def test_favoriting_a_directory_result_keeps_it_after_clearing_the_filter(
monkeypatch,
) -> None:
app = BeefyPlayerApp(
provider="radiobrowser", autoplay=False, offline=True, audio=False
)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
app.offline = False
found = Station(
id="deep-jazz", title="Deep Jazz", provider="radiobrowser"
)
async def fake_search(self, client, query):
return [found]
monkeypatch.setattr(type(app.provider), "search_stations", fake_search)
await pilot.press("slash")
for char in "jazz":
await pilot.press(char)
await pilot.pause(0.4)
assert [station.id for station in app.view] == ["deep-jazz"]
await pilot.press("escape")
await pilot.pause()
app.action_favorite()
await pilot.pause()
assert found.favorite is True
assert "deep-jazz" in app.config.favorites
await pilot.press("escape")
await pilot.pause()
assert app.query == ""
assert app.view[0].id == "deep-jazz"
@pytest.mark.asyncio
async def test_missing_favorites_load_with_the_first_directory_page(
monkeypatch,
) -> None:
app = BeefyPlayerApp(
provider="radiobrowser", autoplay=False, offline=True, audio=False
)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
app.offline = False
favourite = Station(
id="12345678-1234-1234-1234-123456789abc",
title="Favourite",
provider="radiobrowser",
)
app.config.favorites = [favourite.id]
page = StationPage(
stations=[
Station(id="page-0", title="Page Zero", provider="radiobrowser")
],
next_offset=100,
)
async def fake_page(self, client, offset=0):
return page
async def fake_by_ids(self, client, station_ids):
assert station_ids == [favourite.id]
return [favourite]
monkeypatch.setattr(type(app.provider), "fetch_station_page", fake_page)
monkeypatch.setattr(
type(app.provider), "fetch_stations_by_ids", fake_by_ids
)
worker = app._refresh_stations()
await worker.wait()
await pilot.pause()
assert any(
station.id == favourite.id and station.favorite
for station in app.stations
)
assert app.view[0].id == favourite.id
@pytest.mark.asyncio
async def test_a_paginated_catalogue_loads_the_next_page_near_the_end(
monkeypatch,
) -> None:
app = BeefyPlayerApp(
provider="radiobrowser", autoplay=False, offline=True, audio=False
)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
app.offline = False
first = [
Station(
id=f"s{index}", title=f"Station {index}", provider="radiobrowser"
)
for index in range(100)
]
app._set_stations(first)
app._reset_directory_paging(first, has_more=True)
assert app._directory_offset == 100
assert app._directory_exhausted is False
app.index = 0
app._maybe_load_more_stations()
assert app._loading_more is False
seen: list[int] = []
async def fake_page(self, client, offset=0):
seen.append(offset)
return StationPage(
stations=[
Station(id="extra", title="Extra", provider="radiobrowser")
],
next_offset=None,
)
monkeypatch.setattr(type(app.provider), "fetch_station_page", fake_page)
app.index = 99
worker = app._load_more_stations()
await worker.wait()
await pilot.pause()
assert seen == [100]
assert any(station.id == "extra" for station in app.stations)
assert app._directory_exhausted is True
assert app.view[app.index].id == "s99"
@pytest.mark.asyncio
async def test_a_popularity_ordered_catalogue_keeps_its_order() -> None:
app = BeefyPlayerApp(
provider="radiobrowser", autoplay=False, offline=True, audio=False
)
async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause()
app._set_stations(
[
Station(id="zulu", title="Zulu", provider="radiobrowser"),
Station(id="alpha", title="Alpha", provider="radiobrowser"),
]
)
assert [station.title for station in app.view] == ["Zulu", "Alpha"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stage_renders_history_and_listener_count() -> None: async def test_stage_renders_history_and_listener_count() -> None:
app = SomaFMApp(autoplay=False, offline=True, audio=False) app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(110, 40)) as pilot: async with app.run_test(size=(110, 40)) as pilot:
await pilot.pause() await pilot.pause()
station = app.current station = app.current
@@ -122,9 +444,22 @@ async def test_stage_renders_history_and_listener_count() -> None:
assert needle in text assert needle in text
@pytest.mark.asyncio
async def test_splash_names_the_provider() -> None:
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(100, 32)) as pilot:
await pilot.pause()
app.current = None
app.query_one(StagePanel).refresh_stage()
await pilot.pause()
text = rendered_text(app)
assert "SOMA FM" in text
assert "somafm.com/channels.json" in text
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_narrow_layout() -> None: async def test_narrow_layout() -> None:
app = SomaFMApp(autoplay=False, offline=True, audio=False) app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(64, 20)) as pilot: async with app.run_test(size=(64, 20)) as pilot:
await pilot.pause() await pilot.pause()
app.query_one(StagePanel).refresh_stage() app.query_one(StagePanel).refresh_stage()
@@ -137,7 +472,7 @@ async def test_narrow_layout() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stage_shows_bandcamp_link_only_when_present() -> None: async def test_stage_shows_bandcamp_link_only_when_present() -> None:
app = SomaFMApp(autoplay=False, offline=True, audio=False) app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(110, 40)) as pilot: async with app.run_test(size=(110, 40)) as pilot:
await pilot.pause() await pilot.pause()
station = app.current station = app.current
@@ -159,9 +494,68 @@ async def test_stage_shows_bandcamp_link_only_when_present() -> None:
assert "Bandcamp" not in rendered_text(app) assert "Bandcamp" not in rendered_text(app)
@pytest.mark.asyncio
async def test_bandcamp_link_survives_history_refresh(monkeypatch) -> None:
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(110, 40)) as pilot:
await pilot.pause()
station = app.current
assert station is not None
url = "https://jensbuchert.bandcamp.com/track/rauschebart"
old_song = Song(
title="Rauschebart",
artist="Jens Buchert",
album="Gravity",
bandcamp_url=url,
)
station.now_playing = old_song
station.history = [old_song]
async def fake_fetch_history(client, station_id):
assert station_id == station.id
return [
Song(
title="Rauschebart",
artist="Jens Buchert",
album="Gravity",
)
]
monkeypatch.setattr(
type(app.provider), "fetch_history", _history_method(fake_fetch_history)
)
worker = app._fetch_history(station.id)
await worker.wait()
await pilot.pause()
assert station.now_playing is not None
assert station.now_playing is not old_song
assert station.now_playing.bandcamp_url == url
assert "Bandcamp" in rendered_text(app)
@pytest.mark.asyncio
async def test_cached_bandcamp_link_is_reapplied_to_new_song() -> None:
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(110, 40)) as pilot:
await pilot.pause()
station = app.current
assert station is not None
url = "https://jensbuchert.bandcamp.com/track/rauschebart"
song = Song(title="Rauschebart", artist="Jens Buchert", album="Gravity")
app._bandcamp_urls[f"{station.id}|{song.key()}"] = url
station.now_playing = song
app._update_bandcamp()
await pilot.pause()
assert song.bandcamp_url == url
assert "Bandcamp" in rendered_text(app)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_media_session_commands_drive_the_app() -> None: async def test_media_session_commands_drive_the_app() -> None:
app = SomaFMApp(autoplay=False, offline=True, audio=False) app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(100, 30)) as pilot: async with app.run_test(size=(100, 30)) as pilot:
await pilot.pause() await pilot.pause()
# The status bar names whichever backend this platform uses. # The status bar names whichever backend this platform uses.
@@ -177,3 +571,53 @@ async def test_media_session_commands_drive_the_app() -> None:
app.media_actions.put("volume:25") app.media_actions.put("volume:25")
await pilot.pause(0.2) await pilot.pause(0.2)
assert app.volume == 25 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)
+6 -6
View File
@@ -3,11 +3,11 @@ from __future__ import annotations
import pytest import pytest
from PIL import Image from PIL import Image
from somafm_tui import artwork from beefy_player import artwork
from somafm_tui.app import SomaFMApp from beefy_player.app import BeefyPlayerApp
from somafm_tui.widgets import art as art_widgets from beefy_player.widgets import art as art_widgets
from somafm_tui.widgets.art import ArtPane, ITerm2Image, KittyImage from beefy_player.widgets.art import ArtPane, ITerm2Image, KittyImage
from somafm_tui.widgets.stage import StagePanel from beefy_player.widgets.stage import StagePanel
async def _no_fetch(client, url: str) -> None: async def _no_fetch(client, url: str) -> None:
@@ -27,7 +27,7 @@ async def test_art_pane_switches_between_image_and_mosaic(monkeypatch) -> None:
monkeypatch.setattr(artwork, "load_cached", lambda url: None) monkeypatch.setattr(artwork, "load_cached", lambda url: None)
monkeypatch.setattr(artwork, "fetch", _no_fetch) monkeypatch.setattr(artwork, "fetch", _no_fetch)
app = SomaFMApp(autoplay=False, offline=True, audio=False) app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(110, 40)) as pilot: async with app.run_test(size=(110, 40)) as pilot:
await pilot.pause() await pilot.pause()
pane = app.query_one(ArtPane) pane = app.query_one(ArtPane)
+69 -10
View File
@@ -6,12 +6,23 @@ import httpx
import pytest import pytest
from PIL import Image from PIL import Image
from somafm_tui import artwork from beefy_player import artwork
def png_bytes(size=(400, 200)) -> bytes: def png_bytes(size=(400, 200), alpha: int = 180) -> bytes:
buffer = BytesIO() 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() return buffer.getvalue()
@@ -28,17 +39,65 @@ def test_protocol_detection() -> None:
assert artwork.detect_protocol({"ZELLIJ": "1", "TERM_PROGRAM": "WezTerm"}) is None assert artwork.detect_protocol({"ZELLIJ": "1", "TERM_PROGRAM": "WezTerm"}) is None
assert artwork.detect_protocol({"KITTY_PID": "1"}) == "tgp" assert artwork.detect_protocol({"KITTY_PID": "1"}) == "tgp"
assert artwork.detect_protocol({"NO_COLOR": "", "KITTY_WINDOW_ID": "1"}) is None assert artwork.detect_protocol({"NO_COLOR": "", "KITTY_WINDOW_ID": "1"}) is None
assert artwork.detect_protocol({"SOMAFM_IMAGES": "0", "KITTY_WINDOW_ID": "1"}) is None assert artwork.detect_protocol({"BEEFY_IMAGES": "0", "KITTY_WINDOW_ID": "1"}) is None
assert artwork.detect_protocol({"SOMAFM_IMAGES": "tgp"}) == "tgp" assert artwork.detect_protocol({"BEEFY_IMAGES": "tgp"}) == "tgp"
assert artwork.detect_protocol({"SOMAFM_IMAGES": "iterm2"}) == "iterm2" assert artwork.detect_protocol({"BEEFY_IMAGES": "iterm2"}) == "iterm2"
# the pre-rename variable still works
assert artwork.detect_protocol({"SOMAFM_IMAGES": "0"}) is None
def test_fit_image_crops_to_the_cell_box() -> None: def test_fit_image_letterboxes_inside_the_cell_box() -> None:
fitted = artwork.fit_image(png_bytes(), 13, 8) fitted = artwork.fit_image(png_bytes((400, 200)), 13, 8)
assert fitted is not None assert fitted is not None
cell_width, cell_height = artwork.cell_size() cell_width, cell_height = artwork.cell_size()
expected = (13 * cell_width) / (8 * cell_height) assert (fitted.width, fitted.height) == (13 * cell_width, 8 * cell_height)
assert fitted.width / fitted.height == pytest.approx(expected, rel=0.01)
# 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 assert artwork.fit_image(b"not an image", 13, 8) is None
+74 -18
View File
@@ -5,7 +5,7 @@ import json
import httpx import httpx
import pytest import pytest
from somafm_tui import bandcamp from beefy_player import bandcamp
TRACK_PAYLOAD = { TRACK_PAYLOAD = {
"auto": { "auto": {
@@ -151,26 +151,60 @@ def test_pick_track_and_album_ignore_mismatches() -> None:
assert bandcamp.pick_album(results, "Someone Else", "Gravity") is None assert bandcamp.pick_album(results, "Someone Else", "Gravity") is None
def test_build_query_normalises_for_autocomplete() -> None:
assert bandcamp.build_query(
" Diskette Park ", "Dreams — [Extended Mix] (Live)!"
) == "diskette park dreams extended mix live!"
assert bandcamp.build_query("Don't Stop", "AC/DC") == "dont stop acdc"
def test_build_prefixes_grows_in_small_typing_groups() -> None:
assert bandcamp.build_prefixes("diskette park dreams") == [
"dis",
"disket",
"diskette ",
"diskette par",
"diskette park d",
"diskette park dreams",
]
assert bandcamp.build_prefixes("abcde") == ["abcde"]
assert bandcamp.build_prefixes("") == []
def test_matches_is_case_and_whitespace_insensitive() -> None:
assert bandcamp._matches("Jens Buchert", "jens buchert")
assert bandcamp._matches("Rauschebart", "RAUSCHEBART (Live)")
assert not bandcamp._matches("Dreams", "Something Else")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_find_track_uses_track_search_first() -> None: async def test_find_track_uses_track_search_first() -> None:
requests: list[httpx.Request] = [] requests: list[httpx.Request] = []
match_text = "jens buchert ra"
def handler(request: httpx.Request) -> httpx.Response: def handler(request: httpx.Request) -> httpx.Response:
requests.append(request) requests.append(request)
assert request.method == "POST" assert request.method == "POST"
assert json.loads(request.content) == { body = json.loads(request.content)
"search_text": "Jens Buchert Rauschebart", assert body["search_filter"] == "t"
"search_filter": "t", assert body["full_page"] is True
"full_page": True, assert body["fan_id"] is None
"fan_id": None, payload = TRACK_PAYLOAD if body["search_text"] == match_text else {
"auto": {"results": []}
} }
return httpx.Response(200, json=TRACK_PAYLOAD) return httpx.Response(200, json=payload)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
url = await bandcamp.find_track(client, "Jens Buchert", "Gravity", "Rauschebart") url = await bandcamp.find_track(client, "Jens Buchert", "Gravity", "Rauschebart")
assert url == "https://jensbuchert.bandcamp.com/track/rauschebart" assert url == "https://jensbuchert.bandcamp.com/track/rauschebart"
assert len(requests) == 1 assert [json.loads(request.content)["search_text"] for request in requests] == [
"jen",
"jens b",
"jens buch",
"jens buchert",
"jens buchert ra",
]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -184,7 +218,12 @@ async def test_find_track_falls_back_to_album_then_track_on_page() -> None:
if body["search_filter"] == "t": if body["search_filter"] == "t":
return httpx.Response(200, json={"auto": {"results": []}}) return httpx.Response(200, json={"auto": {"results": []}})
if body["search_filter"] == "a": if body["search_filter"] == "a":
return httpx.Response(200, json=ALBUM_PAYLOAD) payload = (
ALBUM_PAYLOAD
if body["search_text"] == "jens buchert"
else {"auto": {"results": []}}
)
return httpx.Response(200, json=payload)
if request.url.path == "/album/gravity": if request.url.path == "/album/gravity":
return httpx.Response(200, text=ALBUM_PAGE_HTML) return httpx.Response(200, text=ALBUM_PAGE_HTML)
return httpx.Response(404) return httpx.Response(404)
@@ -195,15 +234,30 @@ async def test_find_track_falls_back_to_album_then_track_on_page() -> None:
) )
assert url == "https://jensbuchert.bandcamp.com/track/rauschebart" assert url == "https://jensbuchert.bandcamp.com/track/rauschebart"
assert len(requests) == 3 search_bodies = [
search_bodies = [json.loads(request.content) for request in requests[:2]] json.loads(request.content)
assert [body["search_text"] for body in search_bodies] == [ for request in requests
"Jens Buchert Rauschebart", if request.method == "POST"
"Jens Buchert Gravity",
] ]
assert [body["search_filter"] for body in search_bodies] == ["t", "a"] assert [body["search_text"] for body in search_bodies] == [
assert requests[2].method == "GET" "jen",
assert requests[2].url.path == "/album/gravity" "jens b",
"jens buch",
"jens buchert",
"jens buchert ra",
"jens buchert rausc",
"jens buchert rauscheb",
"jens buchert rauschebart",
"jen",
"jens b",
"jens buch",
"jens buchert",
]
assert [body["search_filter"] for body in search_bodies] == ["t"] * 8 + [
"a"
] * 4
assert requests[-1].method == "GET"
assert requests[-1].url.path == "/album/gravity"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -216,4 +270,6 @@ async def test_find_track_returns_none_for_challenge_or_no_results() -> None:
async with httpx.AsyncClient(transport=httpx.MockTransport(challenge)) as client: async with httpx.AsyncClient(transport=httpx.MockTransport(challenge)) as client:
assert await bandcamp.find_track(client, "A", "B", "C") is None assert await bandcamp.find_track(client, "A", "B", "C") is None
assert len(requests) == 2 assert len(requests) == len(bandcamp.build_prefixes("a c")) + len(
bandcamp.build_prefixes("a b")
)
+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],
]
+103 -5
View File
@@ -6,18 +6,27 @@ import types
import pytest import pytest
from rich.cells import cell_len from rich.cells import cell_len
from somafm_tui.widgets.header import HeaderBar 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$") TIME = re.compile(r"\d\d:\d\d:\d\d$")
DATE = re.compile(r"[A-Z][a-z]{2} \d\d [A-Z][a-z]{2}$") DATE = re.compile(r"[A-Z][a-z]{2} \d\d [A-Z][a-z]{2}$")
VOLUME = re.compile(r"vol \d+%$") 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.""" """Render the header without a running app, as plain text per line."""
fake = type("Fake", (), {})() fake = type("Fake", (), {})()
fake.app = types.SimpleNamespace( 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) fake.size = types.SimpleNamespace(width=width, height=height)
return [line.plain.rstrip() for line in HeaderBar.render(fake).renderables] 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: def test_ragged_middle_does_not_shift_the_right_block() -> None:
""""SOMA FM RADIO" and "SAN FRANCISCO" are shorter than """"TILDERADIO" is shorter than "STREAMED BY / FOR USERS OF THE"; the
"LISTENER SUPPORTED"; the right block must not follow their length.""" right block must not follow the length of the middle lines."""
clock, date, volume = render_header(143) clock, date, volume = render_header(143)
assert cell_len(clock) == cell_len(date) == cell_len(volume) == 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.""" """Below the threshold the right block is dropped, not squashed."""
lines = render_header(30) lines = render_header(30)
assert all(":" not in line for line in lines) 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
+1 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import pytest import pytest
from somafm_tui import macos_tap from beefy_player import macos_tap
def test_osstatus_decodes_four_character_codes() -> None: def test_osstatus_decodes_four_character_codes() -> None:
+11 -1
View File
@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
from somafm_tui.__main__ import _build_parser, _list_stations import pytest
from beefy_player.__main__ import _build_parser, _list_stations
def test_parser_defaults_and_flags() -> None: def test_parser_defaults_and_flags() -> None:
@@ -19,6 +21,14 @@ def test_parser_defaults_and_flags() -> None:
assert args.autoplay is True assert args.autoplay is True
assert args.no_mpris is True assert args.no_mpris is True
assert args.spectrum_source == "monitor" assert args.spectrum_source == "monitor"
assert args.provider is None # the config file decides
def test_provider_flag_is_validated() -> None:
parser = _build_parser()
assert parser.parse_args(["--provider", "somafm"]).provider == "somafm"
with pytest.raises(SystemExit):
parser.parse_args(["--provider", "no-such-catalogue"])
def test_list_stations_offline(capsys) -> None: def test_list_stations_offline(capsys) -> None:
+3 -3
View File
@@ -3,8 +3,8 @@ from __future__ import annotations
import queue import queue
import sys import sys
from somafm_tui import mediasession from beefy_player import mediasession
from somafm_tui.mediasession import ( from beefy_player.mediasession import (
MediaSession, MediaSession,
NowPlayingSession, NowPlayingSession,
create_media_session, create_media_session,
@@ -12,7 +12,7 @@ from somafm_tui.mediasession import (
def test_linux_uses_mpris() -> None: def test_linux_uses_mpris() -> None:
from somafm_tui.mpris import MprisService from beefy_player.mpris import MprisService
session = create_media_session(queue.Queue(), enabled=False, platform="linux") session = create_media_session(queue.Queue(), enabled=False, platform="linux")
assert isinstance(session, MprisService) assert isinstance(session, MprisService)
+4 -4
View File
@@ -4,7 +4,7 @@ import queue
import pytest import pytest
from somafm_tui import mpris from beefy_player import mpris
def test_build_metadata() -> None: def test_build_metadata() -> None:
@@ -17,16 +17,16 @@ def test_build_metadata() -> None:
stream_url="https://ice2.somafm.com/groovesalad-256-mp3", stream_url="https://ice2.somafm.com/groovesalad-256-mp3",
track_key="groovesalad|Jens Buchert|Rauschebart|Gravity", track_key="groovesalad|Jens Buchert|Rauschebart|Gravity",
) )
assert metadata["mpris:trackid"].value.startswith("/org/somafm/track/") assert metadata["mpris:trackid"].value.startswith("/org/beefyplayer/track/")
assert metadata["xesam:title"].value == "Rauschebart" assert metadata["xesam:title"].value == "Rauschebart"
assert metadata["xesam:artist"].value == ["Jens Buchert"] assert metadata["xesam:artist"].value == ["Jens Buchert"]
assert metadata["xesam:album"].value == "Gravity" assert metadata["xesam:album"].value == "Gravity"
assert metadata["mpris:artUrl"].value == "https://example.com/art.jpg" assert metadata["mpris:artUrl"].value == "https://example.com/art.jpg"
assert metadata["xesam:url"].value.startswith("https://ice2.") assert metadata["xesam:url"].value.startswith("https://ice2.")
assert metadata["xesam:comment"].value == ["SomaFM · Groove Salad"] assert metadata["xesam:comment"].value == ["Beefy Player · Groove Salad"]
minimal = mpris.build_metadata(station="X", title="", artist="") minimal = mpris.build_metadata(station="X", title="", artist="")
assert minimal["xesam:artist"].value == ["SomaFM"] assert minimal["xesam:artist"].value == ["Beefy Player"]
assert "xesam:album" not in minimal assert "xesam:album" not in minimal
assert "mpris:artUrl" not in minimal assert "mpris:artUrl" not in minimal
+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
+13 -13
View File
@@ -2,13 +2,13 @@ from __future__ import annotations
import os import os
import queue import queue
import sys
import time import time
from pathlib import Path from pathlib import Path
from somafm_tui.player import Player from beefy_player.player import Player
FAKE_MPV = '''#!/usr/bin/env python3 FAKE_MPV = f"#!{sys.executable}\n" + '''import json, os, socket, sys, time
import json, os, socket, sys, time
sock_path = None sock_path = None
volume = 70 volume = 70
@@ -171,27 +171,27 @@ def test_backend_detection_without_tools(tmp_path, monkeypatch) -> None:
def test_media_key_messages_become_player_events() -> None: def test_media_key_messages_become_player_events() -> None:
player = Player() player = Player()
player._handle_mpv_message( player._handle_mpv_message(
{"event": "client-message", "args": ["somafm", "next"]} {"event": "client-message", "args": ["beefy", "next"]}
) )
event = player.events.get_nowait() event = player.events.get_nowait()
assert event.kind == "media" assert event.kind == "media"
assert event.detail == "next" assert event.detail == "next"
player._handle_mpv_message( player._handle_mpv_message(
{"event": "client-message", "args": ["somafm", "toggle"]} {"event": "client-message", "args": ["beefy", "toggle"]}
) )
assert player.events.get_nowait().detail == "toggle" assert player.events.get_nowait().detail == "toggle"
# Foreign namespaces, unknown commands and malformed payloads are ignored. # Foreign namespaces, unknown commands and malformed payloads are ignored.
player._handle_mpv_message({"event": "client-message", "args": ["other", "next"]}) player._handle_mpv_message({"event": "client-message", "args": ["other", "next"]})
player._handle_mpv_message({"event": "client-message", "args": ["somafm", "seek"]}) player._handle_mpv_message({"event": "client-message", "args": ["beefy", "seek"]})
player._handle_mpv_message({"event": "client-message", "args": None}) player._handle_mpv_message({"event": "client-message", "args": None})
player._handle_mpv_message({"event": "client-message"}) player._handle_mpv_message({"event": "client-message"})
assert player.events.empty() assert player.events.empty()
def test_platform_options_never_leak_to_linux(tmp_path, monkeypatch) -> None: def test_platform_options_never_leak_to_linux(tmp_path, monkeypatch) -> None:
import somafm_tui.player as player_module import beefy_player.player as player_module
monkeypatch.setattr(player_module, "RUNTIME_DIR", tmp_path) monkeypatch.setattr(player_module, "RUNTIME_DIR", tmp_path)
assert Player()._platform_options("linux") == [] assert Player()._platform_options("linux") == []
@@ -203,12 +203,12 @@ def test_platform_options_never_leak_to_linux(tmp_path, monkeypatch) -> None:
assert conf.endswith("mpv-input.conf") assert conf.endswith("mpv-input.conf")
bindings = (tmp_path / "mpv-input.conf").read_text() bindings = (tmp_path / "mpv-input.conf").read_text()
for line in ( for line in (
"PLAY script-message somafm toggle", "PLAY script-message beefy toggle",
"PLAYONLY script-message somafm play", "PLAYONLY script-message beefy play",
"PAUSEONLY script-message somafm pause", "PAUSEONLY script-message beefy pause",
"STOP script-message somafm stop", "STOP script-message beefy stop",
"NEXT script-message somafm next", "NEXT script-message beefy next",
"PREV script-message somafm prev", "PREV script-message beefy prev",
): ):
assert line in bindings assert line in bindings
+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 == ""
+328
View File
@@ -0,0 +1,328 @@
from __future__ import annotations
import html
import json
import re
import pytest
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:
svg = html.unescape(app.export_screenshot()).replace("\xa0", " ")
return re.sub(r"<[^>]+>", " ", svg)
def test_registry_lists_somafm_as_the_default() -> None:
# 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()
def test_somafm_provider_describes_its_catalogue() -> None:
provider = get_provider()
assert provider.name == "SomaFM"
assert provider.brand == "SOMA FM"
assert provider.station_source == "somafm.com/channels.json"
assert "somafm.com" in provider.user_agent
stations = provider.fallback_stations()
assert len(stations) > 40
station = stations[0]
assert station.provider == "somafm"
assert station.uid == f"somafm:{station.id}"
assert all("somafm.com" in url for url in provider.fallback_urls(station))
class FakeProvider(RadioProvider):
"""A second catalogue, as small as the interface allows."""
id = "fake"
name = "Fake FM"
homepage = "fake.test"
brand = "FAKE FM"
tagline = "not real radio"
station_source = "fake.test/stations.json"
history_source = "recent.json"
def __init__(self) -> None:
self.station = Station(
id="one", title="Fake One", genre="test", provider=self.id
)
async def fetch_stations(self, client):
return [self.station]
def load_cached_stations(self):
return [self.station]
def fallback_stations(self):
return [self.station]
async def fetch_history(self, client, station_id):
return [Song(title="Tune", artist="Someone")]
def load_cached_history(self, station_id):
return []
@pytest.mark.asyncio
async def test_app_runs_against_a_different_catalogue(monkeypatch) -> None:
"""The UI must not know which catalogue it is showing."""
from beefy_player.app import BeefyPlayerApp
fake = FakeProvider()
monkeypatch.setitem(PROVIDERS, "fake", fake)
app = BeefyPlayerApp(provider="fake", autoplay=False, offline=True, audio=False)
assert app.provider is fake
async with app.run_test(size=(100, 30)) as pilot:
await pilot.pause()
assert app.current is not None
assert app.current.uid == "fake:one"
app.query_one("StagePanel").refresh_stage()
await pilot.pause()
text = rendered_text(app)
assert "FAKE ONE" in text
assert "Fake FM" in app.title
def test_config_remembers_the_provider() -> None:
assert config.Config.load().provider == "somafm"
config.Config(provider="fake").save()
assert config.Config.load().provider == "fake"
def test_legacy_config_is_migrated_once(monkeypatch) -> None:
legacy = config.LEGACY_CONFIG_PATH
legacy.parent.mkdir(parents=True, exist_ok=True)
legacy.write_text(
json.dumps({"favorites": ["defcon"], "volume": 33, "last_station": "dronezone"})
)
loaded = config.Config.load()
assert loaded.favorites == ["defcon"]
assert loaded.volume == 33
assert loaded.last_station == "dronezone"
assert config.CONFIG_PATH.exists()
# the new file wins from then on
config.Config(volume=11).save()
legacy.write_text(json.dumps({"volume": 99}))
assert config.Config.load().volume == 11
def test_environment_variables_prefer_the_beefy_prefix(monkeypatch) -> None:
monkeypatch.setenv("BEEFY_IMAGES", "tgp")
monkeypatch.setenv("SOMAFM_IMAGES", "0")
assert config.env_var("IMAGES") == "tgp"
monkeypatch.delenv("BEEFY_IMAGES")
assert config.env_var("IMAGES") == "0" # the old spelling still works
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") == []
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more