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.
This commit is contained in:
1 parent
4bed90b489
commit
e30ca09627
44 files changed
+329
-192
No files matched your search
@@ -1,19 +1,25 @@
|
||||
# somafm-tui
|
||||
# Beefy Player
|
||||
|
||||

|
||||

|
||||
|
||||
A flashy terminal radio for **[SomaFM](https://somafm.com)** — a live spectrum
|
||||
analyser, song history, and full desktop media integration, drawn with your
|
||||
terminal's own ANSI palette on the terminal's default background so it fits
|
||||
any theme (including transparent terminals).
|
||||
A flashy terminal player for **internet radio** — a live spectrum analyser,
|
||||
song history, and full desktop media integration, drawn with your terminal's
|
||||
own ANSI palette on the terminal's default background so it fits any theme
|
||||
(including transparent terminals).
|
||||
|
||||
The station catalogue is pluggable. Beefy Player ships with
|
||||
**[SomaFM](https://somafm.com)** (its first and, at the moment, only provider),
|
||||
and the UI never talks to a station catalogue directly: it asks a
|
||||
[provider](#providers) for stations, song history and stream URLs. Adding
|
||||
another internet-radio catalogue is a self-contained module, not a rewrite.
|
||||
|
||||
It runs the same way on **Linux** (PulseAudio/PipeWire) and **macOS**
|
||||
(CoreAudio, iTerm2/Kitty/WezTerm): MPRIS on the desktop bus where that exists,
|
||||
macOS Now Playing (Control Center and the media keys) where it does not.
|
||||
|
||||
No existing SomaFM client is used: the station list, playlists, song history,
|
||||
No existing radio client is used: the station list, playlists, song history,
|
||||
audio control, spectrum visualiser and media-session integration are all
|
||||
implemented here from the public SomaFM endpoints and open protocols.
|
||||
implemented here from public endpoints and open protocols.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -33,17 +39,19 @@ implemented here from the public SomaFM endpoints and open protocols.
|
||||
iTerm2 inline-image protocol. Images occupy exactly the ON AIR art cell box
|
||||
and fall back to the procedural ANSI mosaic for other terminals, failed
|
||||
loads, or blocked graphics passthrough. Images are cached in
|
||||
`~/.cache/somafm-tui/artwork`.
|
||||
`~/.cache/beefy-player/artwork`.
|
||||
- **Now playing** — artist, title, album, DJ, listeners, format/bitrate and
|
||||
"played N minutes ago", with procedural ANSI-shaded album art that changes
|
||||
per track. When the current track can be found on Bandcamp, a clickable
|
||||
**Bandcamp ↗** link appears below it; otherwise the line stays hidden.
|
||||
- **Last played** — the full recent history from `somafm.com/songs/<id>.json`.
|
||||
- **Every station** — the whole `channels.json` roster (≈50 channels), live
|
||||
listener counts, genre search.
|
||||
- **Last played** — the full recent history, newest first.
|
||||
- **Every station** — with SomaFM that is the whole `channels.json` roster
|
||||
(≈50 channels), live listener counts and genre search.
|
||||
- **Favourites** that float to the top, remembered between runs.
|
||||
- **Pluggable catalogues** — one `providers` module per radio network, selected
|
||||
with `--provider` and remembered in the config file.
|
||||
- **Desktop media integration** — on Linux an
|
||||
`org.mpris.MediaPlayer2.somafmtui.instance<pid>` player exposes
|
||||
`org.mpris.MediaPlayer2.beefyplayer.instance<pid>` player exposes
|
||||
`PlaybackStatus`, `Metadata` (title/artist/album/artUrl), `Volume`, and
|
||||
`Play`/`Pause`/`PlayPause`/`Stop`/`Next`/`Previous`, so GNOME/KDE widgets,
|
||||
lock screens, `playerctl` and the media keys pick it up. On macOS the audio
|
||||
@@ -58,7 +66,7 @@ implemented here from the public SomaFM endpoints and open protocols.
|
||||
|
||||
## Requirements
|
||||
|
||||
`somafm-tui` is a normal Python application: there is nothing to compile and
|
||||
`beefy-player` is a normal Python application: there is nothing to compile and
|
||||
no distro-specific packaging. It runs on any Linux distribution or macOS with
|
||||
Python **3.10+** and a few command-line audio tools on `$PATH`.
|
||||
|
||||
@@ -127,24 +135,63 @@ needed. If the permission is denied the tap only ever hands out silence, the
|
||||
analyser notices and falls back to `FFT` instead of showing dead bars.
|
||||
|
||||
The tap records the **default output device**, which is also what mpv uses; if
|
||||
you point mpv elsewhere with `SOMAFM_MPV_ARGS='--audio-device=...'` the
|
||||
you point mpv elsewhere with `BEEFY_MPV_ARGS='--audio-device=...'` the
|
||||
analyser falls back to `FFT`.
|
||||
|
||||
Terminal image rendering uses [Pillow](https://python-pillow.org/) and is
|
||||
implemented directly (Kitty TGP + iTerm2 OSC 1337) — no terminal probing.
|
||||
|
||||
## Providers
|
||||
|
||||
A *provider* is one station catalogue. It knows how to fetch the station list,
|
||||
how to fetch a station's recently played songs, and how to turn a station into
|
||||
a stream URL. Everything else — the UI, the player, the spectrum, the media
|
||||
session — is provider-neutral.
|
||||
|
||||
```python
|
||||
# beefy_player/providers/base.py
|
||||
class RadioProvider(ABC):
|
||||
id, name, homepage, brand, tagline, station_source, history_source, user_agent
|
||||
|
||||
async def fetch_stations(self, client) -> list[Station]: ...
|
||||
def load_cached_stations(self) -> list[Station]: ...
|
||||
def fallback_stations(self) -> list[Station]: ...
|
||||
async def fetch_history(self, client, station_id) -> list[Song]: ...
|
||||
def load_cached_history(self, station_id) -> list[Song]: ...
|
||||
def fallback_urls(self, station) -> list[str]: ...
|
||||
async def stream_candidates(self, client, station) -> list[str]: ...
|
||||
```
|
||||
|
||||
SomaFM is the reference implementation, and it is quarantined in its own
|
||||
package: the `channels.json` schema, the `songs/<station>.json` history, the PLS
|
||||
layout and the guessed icecast mirrors all live in
|
||||
`beefy_player/providers/somafm/`. Nothing outside that package imports it; the
|
||||
app only ever holds a `RadioProvider`.
|
||||
|
||||
Adding a catalogue:
|
||||
|
||||
1. create `beefy_player/providers/<slug>/` with a `RadioProvider` subclass,
|
||||
2. register one instance in `beefy_player/providers/__init__.py`,
|
||||
3. run it with `beefy-player --provider <slug>` (or set `"provider"` in
|
||||
`~/.config/beefy-player/config.json`).
|
||||
|
||||
`RadioProvider.stream_candidates()` already implements the common strategy —
|
||||
resolve the station's playlists (with an on-disk cache), then try
|
||||
`fallback_urls()` — so most providers only have to supply the catalogue,
|
||||
history and mirror URLs.
|
||||
|
||||
## Run
|
||||
|
||||
From the repository root, sync the environment and start the app:
|
||||
|
||||
```bash
|
||||
uv sync # create .venv and install exactly what uv.lock pins
|
||||
uv run somafm-tui # run inside that environment
|
||||
uv run beefy-player # run inside that environment
|
||||
```
|
||||
|
||||
You do **not** need to activate the venv. `uv run` uses `.venv` automatically,
|
||||
and re-syncs it first if `pyproject.toml` or `uv.lock` changed. If you prefer a
|
||||
traditional shell, run `source .venv/bin/activate` and then `somafm-tui`.
|
||||
traditional shell, run `source .venv/bin/activate` and then `beefy-player`.
|
||||
|
||||
### Keeping the uv environment in sync
|
||||
|
||||
@@ -163,20 +210,25 @@ rm -rf .venv && uv sync # last resort if the venv was built by an older Python
|
||||
Useful flags:
|
||||
|
||||
```bash
|
||||
uv run somafm-tui --play # start the last station immediately
|
||||
uv run somafm-tui --station defcon # pick a station
|
||||
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
|
||||
uv run somafm-tui --list # print station ids
|
||||
uv run somafm-tui --no-audio # UI demo without mpv/ffmpeg
|
||||
uv run somafm-tui --spectrum-source monitor # force synced output capture
|
||||
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
|
||||
uv run beefy-player --provider somafm # pick a station catalogue
|
||||
uv run beefy-player --play # start the last station immediately
|
||||
uv run beefy-player --station defcon # pick a station
|
||||
uv run beefy-player --volume 55
|
||||
uv run beefy-player --offline # no network; cached/bundled stations
|
||||
uv run beefy-player --no-mpris # no MPRIS / Now Playing, no media keys
|
||||
uv run beefy-player --list # print station ids
|
||||
uv run beefy-player --no-audio # UI demo without mpv/ffmpeg
|
||||
uv run beefy-player --spectrum-source monitor # force synced output capture
|
||||
uv run beefy-player --spectrum-source stream # force the muted ffmpeg tap
|
||||
BEEFY_IMAGES=0 uv run beefy-player # force the ANSI artwork mosaic
|
||||
BEEFY_IMAGES=tgp uv run beefy-player # force Kitty/TGP artwork
|
||||
BEEFY_IMAGES=iterm2 uv run beefy-player # force iTerm2/WezTerm artwork
|
||||
```
|
||||
|
||||
Environment variables are prefixed `BEEFY_`; the pre-rename `SOMAFM_` spellings
|
||||
(`SOMAFM_IMAGES`, `SOMAFM_MPV_ARGS`, `SOMAFM_SPECTRUM_DEVICE`) are still
|
||||
honoured, so an existing shell profile keeps working.
|
||||
|
||||
The UI wants roughly **84×24**; it also works smaller, just less pretty.
|
||||
|
||||
## Keys
|
||||
@@ -194,7 +246,7 @@ The UI wants roughly **84×24**; it also works smaller, just less pretty.
|
||||
| `/` | filter by title, genre, description |
|
||||
| `esc` | clear filter / close search |
|
||||
| `v` | visualiser: mirror → bars → wave → waterfall → radial → braille |
|
||||
| `r` | refresh channels + song history |
|
||||
| `r` | refresh stations + song history |
|
||||
| `g` / `G` | top / bottom |
|
||||
| `?` | help |
|
||||
| `q` | quit |
|
||||
@@ -202,7 +254,8 @@ The UI wants roughly **84×24**; it also works smaller, just less pretty.
|
||||
## Desktop media widgets (MPRIS / Now Playing)
|
||||
|
||||
**Linux** — while playing, the app exports
|
||||
`org.mpris.MediaPlayer2.somafmtui.instance<pid>` on the **session bus**. Check it:
|
||||
`org.mpris.MediaPlayer2.beefyplayer.instance<pid>` on the **session bus**. Check
|
||||
it:
|
||||
|
||||
```bash
|
||||
playerctl -l
|
||||
@@ -210,14 +263,15 @@ playerctl metadata --format '{{artist}} — {{title}} ({{album}})'
|
||||
playerctl play-pause
|
||||
```
|
||||
|
||||
`mpris:artUrl` is the track's album art when SomaFM provides it, otherwise the
|
||||
channel logo. If there is no session bus (SSH without `dbus-run-session`,
|
||||
containers, …) MPRIS is skipped and the status bar shows `mpris off`.
|
||||
`mpris:artUrl` is the track's album art when the provider supplies it,
|
||||
otherwise the channel logo. If there is no session bus (SSH without
|
||||
`dbus-run-session`, containers, …) MPRIS is skipped and the status bar shows
|
||||
`mpris off`.
|
||||
|
||||
**macOS** — mpv owns the system Now Playing session, so Control Center, the
|
||||
menu-bar widget and the keyboard media keys (▶︎/⏸, ⏭, ⏮) all target this app
|
||||
while it is playing. mpv publishes the live stream title (the same
|
||||
“Artist - Title” that SomaFM sends with the audio), and the media keys are
|
||||
“Artist - Title” that the station sends with the audio), and the media keys are
|
||||
routed back into the TUI, so `⏭`/`⏮` really change station and the panel, the
|
||||
spectrum and the station list stay in sync. The status bar shows
|
||||
`nowplaying on`. There is no D-Bus and no extra dependency involved, and
|
||||
@@ -229,63 +283,88 @@ spectrum and the station list stay in sync. The status bar shows
|
||||
|
||||
## How it works
|
||||
|
||||
- `somafm_tui/api.py` — async `httpx` client for:
|
||||
```
|
||||
beefy_player/
|
||||
app.py Textual app: layout, keys, orchestration
|
||||
models.py Song + Station, provider-neutral
|
||||
config.py XDG paths, settings, BEEFY_* env helpers
|
||||
providers/
|
||||
base.py RadioProvider interface + HTTP/playlist toolkit
|
||||
somafm/ the SomaFM catalogue (API, bundled fallback list)
|
||||
player.py mpv over a Unix-socket JSON IPC channel
|
||||
spectrum.py analyser (parec / Core Audio tap / ffmpeg / sim)
|
||||
macos_tap.py macOS output capture (ctypes + Core Audio)
|
||||
mediasession.py platform-neutral desktop media face
|
||||
mpris.py dbus-next MPRIS service
|
||||
artwork.py Kitty TGP / iTerm2 images with an ANSI mosaic fallback
|
||||
bandcamp.py "is this track on Bandcamp?" lookups
|
||||
widgets/ the Textual widgets
|
||||
```
|
||||
|
||||
- `beefy_player/providers/base.py` — the provider interface and the shared
|
||||
toolkit: `httpx` client factory plus `.pls`/`.m3u` parsing and resolution
|
||||
(cached for 8 hours), so providers only describe their own catalogue.
|
||||
- `beefy_player/providers/somafm/api.py` — async `httpx` client for:
|
||||
- `channels.json` → stations, genres, listeners, `.pls` playlist URLs
|
||||
- `<station>.pls` → resolved direct icecast URLs (cached 8h)
|
||||
- `songs/<station>.json` → last played tracks (cached on disk)
|
||||
Playlist and channel caching means the UI opens instantly and still works
|
||||
offline.
|
||||
- `somafm_tui/player.py` — mpv over a Unix-socket JSON IPC channel. Observes
|
||||
- `beefy_player/app.py` — holds one provider and one `Player`, ticks at 20 Hz,
|
||||
and hands the widgets plain `Station`/`Song` objects.
|
||||
- `beefy_player/player.py` — mpv over a Unix-socket JSON IPC channel. Observes
|
||||
`pause`/`volume`, handles mirror failover on `end-file`, auto-restarts a dead
|
||||
engine, and uses `SIGSTOP`/`SIGCONT` when falling back to `ffplay`. On macOS
|
||||
it also asks mpv to publish Now Playing and maps the media keys onto
|
||||
`script-message`s that come back over the same IPC socket.
|
||||
- `somafm_tui/spectrum.py` — analyser (44.1 kHz mono s16le from `parec`, or
|
||||
- `beefy_player/spectrum.py` — analyser (44.1 kHz mono s16le from `parec`, or
|
||||
the device's native float32 rate from the macOS tap). Prefers capturing the
|
||||
real output: `parec` (PulseAudio/PipeWire monitor) on Linux, or a native
|
||||
Core Audio process tap on macOS (SYNC, see `macos_tap.py`). Otherwise a
|
||||
muted low-latency `ffmpeg` decode of the stream (FFT). Hann-windowed
|
||||
2048-point FFT, log band grouping, auto-gain, fast attack/decay smoothing,
|
||||
peak hold and waveform downsampling.
|
||||
- `somafm_tui/macos_tap.py` — the macOS output capture, in pure `ctypes`
|
||||
- `beefy_player/macos_tap.py` — the macOS output capture, in pure `ctypes`
|
||||
against Core Audio: `CATapDescription` → `AudioHardwareCreateProcessTap` →
|
||||
private aggregate device → IOProc. No driver, no helper binary, no ffmpeg.
|
||||
- `somafm_tui/mediasession.py` — picks the desktop media backend (MPRIS on
|
||||
- `beefy_player/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
|
||||
- `beefy_player/mpris.py` — `dbus-next` MPRIS service in its own thread with a
|
||||
thread-safe command queue back into the UI (only loaded on D-Bus platforms).
|
||||
- `somafm_tui/bandcamp.py` — searches Bandcamp's public autocomplete API for
|
||||
- `beefy_player/bandcamp.py` — searches Bandcamp's public autocomplete API for
|
||||
the now-playing track (artist + track first, then artist + album, then the
|
||||
album page) and returns a track URL when there is a match.
|
||||
- `somafm_tui/widgets/` — Textual widgets rendered with native ANSI colours;
|
||||
- `beefy_player/widgets/` — Textual widgets rendered with native ANSI colours;
|
||||
backgrounds are `ansi_default` (`SGR 49`), so transparent terminals stay
|
||||
transparent.
|
||||
|
||||
### Files
|
||||
|
||||
```
|
||||
~/.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
|
||||
~/.config/beefy-player/config.json favourites, volume, last station, mode
|
||||
~/.cache/beefy-player/channels.json last good station list
|
||||
~/.cache/beefy-player/playlists.json resolved stream URLs
|
||||
~/.cache/beefy-player/songs/<id>.json song history snapshots
|
||||
~/.cache/beefy-player/artwork/<sha1> cached album/channel artwork
|
||||
```
|
||||
|
||||
These stay in the XDG locations on every platform (macOS included), so the
|
||||
same dotfiles/config work everywhere; `XDG_CONFIG_HOME`, `XDG_CACHE_HOME` and
|
||||
`XDG_RUNTIME_DIR` are honoured when set.
|
||||
`XDG_RUNTIME_DIR` are honoured when set. The app started life as
|
||||
`somafm-tui`: on first run a `~/.config/somafm-tui/config.json` is copied over
|
||||
so favourites and volume survive the rename.
|
||||
|
||||
## Notes / troubleshooting
|
||||
|
||||
- **Artwork shows the mosaic?** Real images are auto-enabled for Kitty,
|
||||
iTerm2 and WezTerm. Force a protocol with `SOMAFM_IMAGES=tgp` (Kitty/TGP) or
|
||||
`SOMAFM_IMAGES=iterm2` (iTerm2/WezTerm); `SOMAFM_IMAGES=0` forces the ANSI
|
||||
iTerm2 and WezTerm. Force a protocol with `BEEFY_IMAGES=tgp` (Kitty/TGP) or
|
||||
`BEEFY_IMAGES=iterm2` (iTerm2/WezTerm); `BEEFY_IMAGES=0` forces the ANSI
|
||||
mosaic. iTerm2 on macOS is detected from `TERM_PROGRAM`/`LC_TERMINAL`, and
|
||||
empty or `0` values in `TMUX`/`ZELLIJ`/`STY` (exported by some wrappers) are
|
||||
ignored so they cannot downgrade it. Kitty inside tmux is attempted through
|
||||
tmux passthrough (enable `set -g allow-passthrough on`); iTerm2/WezTerm
|
||||
inside tmux default to the mosaic. Images are cached under
|
||||
`~/.cache/somafm-tui/artwork`.
|
||||
`~/.cache/beefy-player/artwork`.
|
||||
- **Clock shows UTC?** The clock and song history use the system zone from
|
||||
`/etc/localtime`; a non-UTC `TZ` overrides it. A container or dev shell can
|
||||
inherit a build-time `TZ=UTC`, which the app deliberately ignores; bind the
|
||||
@@ -293,23 +372,23 @@ same dotfiles/config work everywhere; `XDG_CONFIG_HOME`, `XDG_CACHE_HOME` and
|
||||
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
|
||||
run beefy-player` starts a private bus for testing, but desktop widgets won't
|
||||
see it. macOS does not use MPRIS at all — see “Now Playing” above.
|
||||
- **No audio?** `somafm-tui` needs `mpv` (preferred) or `ffplay`. Without
|
||||
- **No audio?** `beefy-player` needs `mpv` (preferred) or `ffplay`. Without
|
||||
either, the UI still runs and tells you. Extra mpv args can be injected with
|
||||
`SOMAFM_MPV_ARGS` (e.g. `SOMAFM_MPV_ARGS='--audio-device=...'`, which is the
|
||||
`BEEFY_MPV_ARGS` (e.g. `BEEFY_MPV_ARGS='--audio-device=...'`, which is the
|
||||
macOS way to choose an output device).
|
||||
- **Spectrum says `FFT` instead of `SYNC`?** The monitor is unavailable or
|
||||
failed, or you forced the stream tap. The bars are still real audio, but the
|
||||
second stream can drift relative to playback. Use `--spectrum-source
|
||||
monitor` to force sync, or `SOMAFM_SPECTRUM_DEVICE=<source>` on Linux to pick
|
||||
monitor` to force sync, or `BEEFY_SPECTRUM_DEVICE=<source>` on Linux to pick
|
||||
a specific monitor. `SIM` means the bars are synthesized.
|
||||
- **Diagnose artwork:** `uv run somafm-tui --diagnose-images` prints the
|
||||
- **Diagnose artwork:** `uv run beefy-player --diagnose-images` prints the
|
||||
detected protocol, widget class, terminal env, dependency versions and cache
|
||||
path. If `protocol` is `None`, force it with `SOMAFM_IMAGES=tgp` (Kitty) or
|
||||
`SOMAFM_IMAGES=iterm2` (iTerm2/WezTerm).
|
||||
path. If `protocol` is `None`, force it with `BEEFY_IMAGES=tgp` (Kitty) or
|
||||
`BEEFY_IMAGES=iterm2` (iTerm2/WezTerm).
|
||||
- **`SYNC` but the bars are flat?** mpv may be playing to a different sink (or
|
||||
directly to ALSA). Point `SOMAFM_SPECTRUM_DEVICE` at that sink's monitor, or
|
||||
directly to ALSA). Point `BEEFY_SPECTRUM_DEVICE` at that sink's monitor, or
|
||||
use `--spectrum-source stream`.
|
||||
- **macOS: spectrum stays `FFT`?** Grant your terminal **System Audio
|
||||
Recording** permission (*System Settings → Privacy & Security → System Audio
|
||||
@@ -340,7 +419,7 @@ manylinux wheels such as NumPy that expect `libstdc++.so.6` in the usual
|
||||
```bash
|
||||
nix-shell # enter the FHS dev shell
|
||||
uv sync
|
||||
uv run somafm-tui
|
||||
uv run beefy-player
|
||||
```
|
||||
|
||||
- If you see `libstdc++.so.6: cannot open shared object file`, make sure you
|
||||
|
||||
Reference in new issue
Block a user