From 03297e875122df7cafa7d654beeb0b9409b6cc90 Mon Sep 17 00:00:00 2001 From: beefcarver Date: Wed, 4 Nov 2026 15:11:49 -0500 Subject: [PATCH] docs(readme): rewrite for public release --- README.md | 689 ++++++++++++------------------------------------------ 1 file changed, 143 insertions(+), 546 deletions(-) diff --git a/README.md b/README.md index 4d0ccf6..b8fbab2 100644 --- a/README.md +++ b/README.md @@ -3,370 +3,121 @@ ![beefy-player demo](assets/beefy-player-demo.gif) A flashy terminal player for **internet radio** — a live spectrum analyser, -song history, and full desktop media integration, drawn with your terminal's -own ANSI palette on the terminal's default background so it fits any theme -(including transparent terminals). +song history and album art, with desktop media integration on Linux and macOS. +It draws with your terminal's own ANSI palette on the terminal's default +background, so it fits any theme, including transparent terminals. -The station catalogue is pluggable. Beefy Player ships with thirteen catalogues -— **[SomaFM](https://somafm.com)** (≈50 curated channels), -**[Radio Paradise](https://radioparadise.com)** (five mixes), the community -stations **[tilderadio](https://tilderadio.org)**, -**[Bassdrive](https://bassdrive.com)**, **[dublab](https://dublab.com)**, -**[Rinse FM](https://rinse.fm)**, **[Refuge Worldwide](https://refugeworldwide.com)**, -**[LYL Radio](https://lyl.live)**, **[The Lot Radio](https://thelotradio.com)**, -**[Cashmere Radio](https://cashmereradio.com)**, -**[Datafruits](https://datafruits.fm)** and **[aNONradio](https://anonradio.net)**, -plus the **[Radio Browser](https://www.radio-browser.info)** directory -(300 stations) — and the UI never talks to a station catalogue directly: it asks a -[provider](#providers) for stations, song history and stream URLs. Press `p` to -switch catalogue without restarting. Adding another internet-radio catalogue is -a self-contained module, not a rewrite. - -It runs the same way on **Linux** (PulseAudio/PipeWire) and **macOS** -(CoreAudio, iTerm2/Kitty/WezTerm): MPRIS on the desktop bus where that exists, -macOS Now Playing (Control Center and the media keys) where it does not. - -No existing radio client is used: the station list, playlists, song history, -audio control, spectrum visualiser and media-session integration are all -implemented here from public endpoints and open protocols. +Thirteen station catalogues ship today, from SomaFM's curated channels to the +62,000-station Radio Browser directory, loaded as you scroll and searched live +as you type. Press `p` to switch catalogue without restarting. ## Features -- **Live spectrum analyser** — 72 log-spaced bands with peak caps, a moving - gradient taken from your terminal's ANSI palette and a symmetric mirrored - spectrum. Six modes (`v`): mirrored bars, plain bars, oscilloscope, - scrolling waterfall spectrogram, a radial spectrum wrapped around the - centre, and a high-resolution braille oscilloscope (2×4 dots per cell). -- **Synced spectrum, not a guess** — by default the analyser records your - actual audio output, so the bars follow what you hear (`SYNC`): the - PulseAudio/PipeWire monitor via `parec` on Linux, and a native Core Audio - process tap on macOS (no driver, no BlackHole, nothing to install). Without - an output capture it falls back to a muted second `ffmpeg` decode of the - stream (`FFT`, can drift), then to a synthesized display (`SIM`). -- **Real artwork on iTerm2, Kitty and WezTerm** — Kitty uses the kitty - graphics protocol; iTerm2 (including on macOS) and WezTerm use their native - iTerm2 inline-image protocol. Images occupy exactly the ON AIR art cell box - and fall back to the procedural ANSI mosaic for other terminals, failed - loads, or blocked graphics passthrough. Images are cached in - `~/.cache/beefy-player/artwork`, letterboxed rather than cropped, and an - image that carries an alpha channel is composited onto black — a transparent - PNG drawn over the mosaic reads as noise behind a station logo. -- **Now playing** — artist, title, album, DJ, listeners, format/bitrate and - "played N minutes ago", with procedural ANSI-shaded album art that changes - per track. When the current track can be found on Bandcamp, a clickable - **Bandcamp ↗** link appears below it; otherwise the line stays hidden. - Catalogues can opt out with `bandcamp = False` (tilderadio does), and a - provider can fill the `Station.presenter` line instead — tilderadio shows - `auto dj · library rotation` between live shows and `dj ` during one. -- **Last played** — the full recent history, newest first. -- **Every station** — with SomaFM that is the whole `channels.json` roster - (≈50 channels), live listener counts and genre search; tilderadio is a - single-station catalogue, and the list simply holds one. Long lists get a grey - thumb down the right edge (no rail) and a `12/51` position in the pane title; - `pgup`/`pgdn` move a screen at a time. -- **Favourites** that float to the top, remembered between runs. -- **Pluggable catalogues** — one `providers` module per radio network, with a - `p` picker in the UI, `--provider` on the command line, and the choice - remembered per catalogue (including the last station you tuned). Switching to - a single-station catalogue tunes it straight away; a multi-station one waits - for `enter`. -- **A hero per catalogue** — the top-left art is the catalogue's own name in a - shared half-block font: SomaFM in the spectrum gradient, tilderadio in the - terminal's default colour, Bassdrive in its two blues. Every hero is three - rows tall, so the header keeps one height. -- **Schedule pane** — a single-stream catalogue gets a broadcast schedule - instead of a list of one station: who is on air now, since/until when, and - the upcoming shows grouped by day. -- **Desktop media integration** — on Linux an - `org.mpris.MediaPlayer2.beefyplayer.instance` player exposes - `PlaybackStatus`, `Metadata` (title/artist/album/artUrl), `Volume`, and - `Play`/`Pause`/`PlayPause`/`Stop`/`Next`/`Previous`, so GNOME/KDE widgets, - lock screens, `playerctl` and the media keys pick it up. On macOS the audio - engine (mpv) publishes to the system **Now Playing** session, so Control - Center, the menu bar and the keyboard media keys drive the app the same way. -- **Sixteen palettes** — every colour comes from one ladder of ANSI names per - theme: the spectrum analyser, the album-art mosaic, the provider wordmarks - and the pane corners all draw from it. `c` cycles spectrum, rainbow, ocean, - ember, mint, sunset, violet, acid, neon, sand, blood, forest, ice, steel, - candy and mono; the choice is remembered. -- **Theme-aware** — every accent is emitted as native ANSI (not RGB), and the - background is the terminal default (`SGR 49`), so transparent terminals stay - transparent and the TUI adopts your shell/terminal colour scheme. -- **Graceful degradation** — works with cached data offline, falls back to a - bundled station list, reconnects across ICE mirrors automatically, and uses - `ffplay` if `mpv` is missing. +- **Live spectrum analyser** — 72 log-spaced bands with peak caps, a palette + gradient and a mirrored spectrum. Six modes (`v`): mirrored bars, plain bars, + oscilloscope, scrolling waterfall, radial spectrum and a high-resolution + braille oscilloscope. +- **Synced to what you hear** — the analyser listens to your actual audio + output by default, so the bars follow the music. If that is unavailable it + falls back to decoding the stream, then to a simulated display. +- **Now playing and history** — artist, title, album, DJ, listeners, bitrate + and "played N minutes ago", plus the full recent history. A clickable + **Bandcamp ↗** link appears when the current track can be found there. +- **Real album art** — native images in Kitty, iTerm2 and WezTerm; an + ANSI-shaded mosaic in every other terminal. +- **Thirteen station catalogues** — SomaFM (≈50 channels), Radio Paradise, + tilderadio, Bassdrive, dublab, Rinse FM, Refuge Worldwide, LYL Radio, The Lot + Radio, Cashmere Radio, Datafruits, aNONradio and the Radio Browser directory + (loaded as you scroll in popularity order, with live search across all + 62,000). +- **Favourites, search and schedules** — favourites float to the top and are + remembered between runs, including directory stations outside the loaded + page; search by title, genre or description, with live server-side search + across the Radio Browser directory; single-stream stations show their + broadcast schedule instead of a one-item list. +- **Desktop media integration** — MPRIS on Linux (GNOME/KDE widgets, lock + screens, `playerctl` and the media keys) and Now Playing on macOS (Control + Center and the media keys). +- **Sixteen palettes, theme-aware** — every colour is native ANSI against the + terminal's default background, so transparent terminals stay transparent and + the TUI follows your colour scheme. Press `c` to cycle palettes. +- **Graceful offline** — cached stations and schedules, a bundled fallback + list, automatic mirror failover and `ffplay` when `mpv` is missing. -## Requirements +## Installation -`beefy-player` is a normal Python application: there is nothing to compile and -no distro-specific packaging. It runs on any Linux distribution or macOS with -Python **3.10+** and a few command-line audio tools on `$PATH`. - -| tool | why | -| ---- | --- | -| `python3` | runtime (3.10 or newer; no exact version pin) | -| [`uv`](https://docs.astral.sh/uv/) | creates the venv and installs every Python dependency from `uv.lock` | -| `mpv` | playback and JSON IPC control | -| `ffmpeg` | muted stream decode for the fallback analyser | -| `parec` / `pactl` *(Linux)* | synced monitor capture for the spectrum (`pulseaudio-utils`; works with PipeWire) | -| D-Bus session bus *(Linux)* | MPRIS desktop media integration (`playerctl` is handy for testing) | - -On macOS the spectrum capture is part of the app: it uses the Core Audio -process-tap API (macOS 14.2 or newer), so there is no loopback driver to -install. - -On macOS the media integration needs nothing extra: mpv is what the system's -Now Playing session (Control Center, media keys) talks to. - -Install `uv` once (skip if it is already there): +## Usage ```bash -curl -LsSf https://astral.sh/uv/install.sh | sh +beefy-player ``` -`brew install uv`, `pipx install uv`, or a distro package also work. If your -system Python is older than 3.10, let uv manage one: `uv python install 3.12`. - -### Linux - -Install these with your package manager. Names are usually: - -```bash -# Debian / Ubuntu -sudo apt install python3 mpv ffmpeg pulseaudio-utils dbus playerctl - -# Fedora (enable RPM Fusion for ffmpeg if needed) -sudo dnf install python3 mpv ffmpeg pulseaudio-utils dbus-tools playerctl - -# Arch / Manjaro -sudo pacman -S python mpv ffmpeg libpulse dbus playerctl - -# openSUSE -sudo zypper install python3 mpv ffmpeg pulseaudio-utils dbus-1-tools playerctl -``` - -For other distributions, install the equivalents. On a headless machine you -can skip the audio/D-Bus tools and use `--no-audio` for a UI-only demo, though -the synced spectrum needs `parec`/`pactl`. - -### macOS - -```bash -brew install mpv ffmpeg uv # ffplay ships with ffmpeg -``` - -That is enough to run: playback, artwork in iTerm2/Kitty/WezTerm, the synced -Core Audio spectrum tap and the macOS Now Playing integration all work out of -the box. - -The first time the analyser starts it asks macOS for **System Audio -Recording** permission — a one-off prompt for your terminal (iTerm2, Kitty, -WezTerm, …). Approve it under *System Settings → Privacy & Security → System -Audio Recording*; no loopback driver, Multi-Output Device or audio routing is -needed. If the permission is denied the tap only ever hands out silence, the -analyser notices and falls back to `FFT` instead of showing dead bars. - -The tap records the **default output device**, which is also what mpv uses; if -you point mpv elsewhere with `BEEFY_MPV_ARGS='--audio-device=...'` the -analyser falls back to `FFT`. - -Terminal image rendering uses [Pillow](https://python-pillow.org/) and is -implemented directly (Kitty TGP + iTerm2 OSC 1337) — no terminal probing. - -## Providers - -A *provider* is one station catalogue. It knows how to fetch the station list, -how to fetch a station's recently played songs, and how to turn a station into -a stream URL. Everything else — the UI, the player, the spectrum, the media -session — is provider-neutral. - -```python -# beefy_player/providers/base.py -class RadioProvider(ABC): - id, name, homepage, brand, tagline, station_source, history_source, user_agent - - hero, hero_gradient, hero_lines # the header art and its caption - has_schedule # schedule pane instead of a list - bandcamp # look tracks up on Bandcamp? - - async def fetch_stations(self, client) -> list[Station]: ... - def load_cached_stations(self) -> list[Station]: ... - def fallback_stations(self) -> list[Station]: ... - async def fetch_history(self, client, station_id) -> list[Song]: ... - def load_cached_history(self, station_id) -> list[Song]: ... - async def fetch_schedule(self, client) -> list[Broadcast]: ... - def load_cached_schedule(self) -> list[Broadcast]: ... - def fallback_urls(self, station) -> list[str]: ... - async def stream_candidates(self, client, station) -> list[str]: ... -``` - -`hero` rows are rich markup (half blocks); with `hero_gradient` the header -paints plain glyphs with the app's spectrum palette instead, which is how the -SomaFM wordmark stays theme-aware. `has_schedule` swaps the left pane for the -schedule view — the pane asks the provider for `Broadcast` objects (name, DJ, -start, end, live) and never learns where they came from. - -SomaFM is the reference implementation, and it is quarantined in its own -package: the `channels.json` schema, the `songs/.json` history, the PLS -layout and the guessed icecast mirrors all live in -`beefy_player/providers/somafm/`. Nothing outside that package imports it; the -app only ever holds a `RadioProvider`. - -Thirteen catalogues ship today: - -| slug | catalogue | stations | what is on | relays | -| --- | --- | --- | --- | --- | -| `somafm` | [SomaFM](https://somafm.com) | `channels.json` (≈50) | `songs/.json` | `.pls` → icecast mirrors | -| `radioparadise` | [Radio Paradise](https://radioparadise.com) | five mixes | `api/now_playing` (artist, album, cover) | FLAC / AAC / MP3 icecast | -| `tilderadio` | [tilderadio](https://tilderadio.org) | 1 + schedule | `/api/now/` | AzuraCast mounts | -| `bassdrive` | [Bassdrive](https://bassdrive.com) | 1 + schedule | icecast `display-title` (the show) | three mounts + mirrors | -| `dublab` | [dublab](https://dublab.com) | 1 + schedule | dublab's calendar | Airtime mp3 | -| `rinse` | [Rinse FM](https://rinse.fm) | 3 | — | proxy mounts (aac+) | -| `refuge` | [Refuge Worldwide](https://refugeworldwide.com) | 1 | `public.radio.co` status | radio.co mp3 | -| `lyl` | [LYL Radio](https://lyl.live) | 1 | — | icecast mp3 | -| `lot` | [The Lot Radio](https://thelotradio.com) | 1 | — | HLS | -| `cashmere` | [Cashmere Radio](https://cashmereradio.com) | 1 | — | Airtime ogg/mp3 | -| `datafruits` | [Datafruits](https://datafruits.fm) | 1 | streampusher icecast | relay mp3 | -| `anonradio` | [aNONradio](https://anonradio.net) | 1 | icecast title | icecast mp3 | -| `radiobrowser` | [Radio Browser](https://www.radio-browser.info) | 300 (directory) | — | whatever each station publishes | - -They are deliberately different shapes: fifty channels with PLS playlists, a -single AzuraCast stream that carries its own history, and an Icecast relay whose -"now playing" is the *show* rather than the track. That is the seam working. - -The single-stream catalogues also use the optional capabilities: where a -schedule exists (`tilderadio`, `bassdrive`, `dublab`) the left pane is that -schedule rather than a list containing one station, and none of them do -Bandcamp lookups. Bassdrive is the extreme case — Icecast reports one title and -keeps no history, so the provider maps the title to `Station.dj`/`presenter` -and keeps a rolling record of the shows it has heard. - -The community stations share one base class, -`providers/community/base.py`, which knows how to read an Icecast -`status-json.xsl`, a `public.radio.co` status, a scheduled show, or nothing at -all; each station is then ten lines of data (relays, wordmark, tagline). Where -a relay publishes no metadata (Rinse, LYL, The Lot, Cashmere) the app simply -plays it and says so, rather than inventing a track. - -`radiobrowser` is the directory case: three hundred of the most-clicked working -stations from the community database, keyless and public-domain, with no -per-station history at all — breadth rather than depth, and the one catalogue -where ON AIR legitimately has nothing to say. - -### Header wordmarks - -All three heroes come from one font, `beefy_player/providers/wordmark.py`: -four-column capitals six pixels tall, emitted as three rows of half blocks -(`▀`/`▄`/`█`) — the shape of the original SomaFM logo. A provider's `hero.py` -is one line of data: - -```python -# beefy_player/providers/bassdrive/hero.py -HERO = render("BASSDRIVE", ["bright_cyan"] * 4 + ["cyan"] * 5) -``` - -`render()` takes one optional style per character and groups equal neighbours -into markup spans; with no styles the wordmark inherits the terminal's default -foreground (tilderadio). Add a glyph to `GLYPHS` when a catalogue needs a letter -the font does not have yet — `▀█▀` is the I, one pixel thick on purpose. - -Image logos are a different problem: `tools/ansi_art.py` converts a PNG into -half-block art (`--key-white` for logos on white, `--preview out.png` to see the -result). No shipped catalogue needs it today, but it is the tool to reach for -when a station's identity is a picture rather than a word. - -Adding a catalogue: - -1. create `beefy_player/providers//` with a `RadioProvider` subclass, -2. register one instance in `beefy_player/providers/__init__.py`, -3. run it with `beefy-player --provider `, press `p` in the UI, or set - `"provider"` in `~/.config/beefy-player/config.json`. - -`RadioProvider.stream_candidates()` already implements the common strategy — -resolve the station's playlists (with an on-disk cache), then try -`fallback_urls()` — so most providers only have to supply the catalogue, -history and mirror URLs. - -## Run - -From the repository root, sync the environment and start the app: - -```bash -uv sync # create .venv and install exactly what uv.lock pins -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 `beefy-player`. - -### Keeping the uv environment in sync - -`uv sync` is the command to remember. It is incremental, safe to re-run, and -should be run after pulling changes, switching branches, or editing -`pyproject.toml`: - -```bash -uv sync # make .venv match uv.lock -uv sync --frozen # strict: fail if uv.lock is out of date, never rewrite it -uv lock --upgrade # refresh uv.lock to the newest allowed versions -uv sync --upgrade # upgrade dependencies and sync .venv in one step -rm -rf .venv && uv sync # last resort if the venv was built by an older Python -``` - -Useful flags: - -```bash -uv run beefy-player --provider somafm # pick a station catalogue -uv run beefy-player --provider tilderadio # one community stream, tildeverse -uv run beefy-player --play # start the last station immediately -uv run beefy-player --station defcon # pick a station -uv run beefy-player --volume 55 -uv run beefy-player --offline # no network; cached/bundled stations -uv run beefy-player --no-mpris # no MPRIS / Now Playing, no media keys -uv run beefy-player --list # print station ids -uv run beefy-player --no-audio # UI demo without mpv/ffmpeg -uv run beefy-player --spectrum-source monitor # force synced output capture -uv run beefy-player --spectrum-source stream # force the muted ffmpeg tap -BEEFY_IMAGES=0 uv run beefy-player # force the ANSI artwork mosaic -BEEFY_IMAGES=tgp uv run beefy-player # force Kitty/TGP artwork -BEEFY_IMAGES=iterm2 uv run beefy-player # force iTerm2/WezTerm artwork -``` - -Environment variables are prefixed `BEEFY_`; the pre-rename `SOMAFM_` spellings -(`SOMAFM_IMAGES`, `SOMAFM_MPV_ARGS`, `SOMAFM_SPECTRUM_DEVICE`) are still -honoured, so an existing shell profile keeps working. - The UI wants roughly **84×24**; it also works smaller, just less pretty. -## Keys +| flag | what it does | +| --- | --- | +| `--provider ` | pick a station catalogue | +| `--station ` | pick a station (see `--list`) | +| `--play` | tune the last station right away | +| `--list` | print station ids for the current catalogue | +| `--volume <0-100>` | set the initial volume | +| `--offline` | no network; use cached and bundled stations | +| `--no-audio` | UI demo without `mpv` / `ffmpeg` | +| `--no-mpris` | no MPRIS / Now Playing, no media keys | +| `--spectrum-source monitor\|stream` | force synced output capture or the stream tap | -| key | action | -| -------------- | --------------------------------------------- | -| `↑` / `↓`, `j` / `k` | move through stations | -| `pgup` / `pgdn` | page through stations | -| `enter` | tune in to the selected station | -| `space` | play / pause | -| `s` | stop | -| `]` / `[` | next / previous station (tunes immediately) | -| `+` / `-` | volume | -| `m` | mute | -| `f` | favourite (favourites sort to the top) | -| `/` | filter by title, genre, description | -| `esc` | clear filter / close search | -| `v` | visualiser: mirror → bars → wave → waterfall → radial → braille | -| `c` | colour palette (16 of them, remembered) | -| `r` | refresh stations, schedule + song history | -| `p` | switch station catalogue (SomaFM / tilderadio)| -| `g` / `G` | top / bottom | -| `?` | help | -| `q` | quit | +Environment variables are prefixed `BEEFY_`: `BEEFY_IMAGES` (`tgp`, `iterm2` +or `0`), `BEEFY_MPV_ARGS` and `BEEFY_SPECTRUM_DEVICE`. -## Desktop media widgets (MPRIS / Now Playing) +### Keys -**Linux** — while playing, the app exports -`org.mpris.MediaPlayer2.beefyplayer.instance` on the **session bus**. Check -it: +| key | action | +| --- | --- | +| `↑` / `↓`, `j` / `k` | move through stations | +| `pgup` / `pgdn` | page through stations | +| `enter` | tune in to the selected station | +| `space` | play / pause | +| `s` | stop | +| `]` / `[` | next / previous station (tunes immediately) | +| `+` / `-` | volume | +| `m` | mute | +| `f` | favourite (favourites sort to the top) | +| `/` | filter by title, genre, description | +| `esc` | close the search box; press again to clear the filter | +| `v` | visualiser: mirror → bars → wave → waterfall → radial → braille | +| `c` | colour palette (16 of them, remembered) | +| `r` | refresh stations, schedule and song history | +| `p` | switch station catalogue | +| `g` / `G` | top / bottom | +| `?` | help | +| `q` | quit | + +## Station catalogues + +| catalogue | slug | stations | +| --- | --- | --- | +| [SomaFM](https://somafm.com) | `somafm` | ≈50 curated channels | +| [Radio Paradise](https://radioparadise.com) | `radioparadise` | 5 mixes | +| [tilderadio](https://tilderadio.org) | `tilderadio` | 1 + schedule | +| [Bassdrive](https://bassdrive.com) | `bassdrive` | 1 + schedule | +| [dublab](https://dublab.com) | `dublab` | 1 + schedule | +| [Rinse FM](https://rinse.fm) | `rinse` | 3 | +| [Refuge Worldwide](https://refugeworldwide.com) | `refuge` | 1 | +| [LYL Radio](https://lyl.live) | `lyl` | 1 | +| [The Lot Radio](https://thelotradio.com) | `lot` | 1 | +| [Cashmere Radio](https://cashmereradio.com) | `cashmere` | 1 | +| [Datafruits](https://datafruits.fm) | `datafruits` | 1 | +| [aNONradio](https://anonradio.net) | `anonradio` | 1 | +| [Radio Browser](https://www.radio-browser.info) | `radiobrowser` | 62k · paged + live search | + +Pick one with `p` in the UI, `--provider ` 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` player on the session bus, +so GNOME/KDE widgets, lock screens, `playerctl` and the media keys pick it up: ```bash playerctl -l @@ -374,216 +125,62 @@ playerctl metadata --format '{{artist}} — {{title}} ({{album}})' playerctl play-pause ``` -`mpris:artUrl` is the track's album art when the provider supplies it, -otherwise the channel logo. If there is no session bus (SSH without -`dbus-run-session`, containers, …) MPRIS is skipped and the status bar shows -`mpris off`. +If there is no session bus (SSH without `dbus-run-session`, containers, …) +MPRIS is skipped and the status bar shows `mpris off`. -**macOS** — mpv owns the system Now Playing session, so Control Center, the -menu-bar widget and the keyboard media keys (▶︎/⏸, ⏭, ⏮) all target this app -while it is playing. mpv publishes the live stream title (the same -“Artist - Title” that the station sends with the audio), and the media keys are -routed back into the TUI, so `⏭`/`⏮` really change station and the panel, the -spectrum and the station list stay in sync. The status bar shows -`nowplaying on`. There is no D-Bus and no extra dependency involved, and -`--no-mpris` (also spelled `--no-media`) turns the media keys off again. +**macOS** — mpv publishes to the system Now Playing session, so Control Center, +the menu bar and the media keys drive the app while it plays. The status bar +shows `nowplaying on`. `--no-mpris` (also spelled `--no-media`) turns the media +keys off. -> macOS shows one Now Playing app at a time. If Music/Safari/another mpv is -> playing, the media keys go there instead — quit it or pause it to hand the -> keys back. +> macOS shows one Now Playing app at a time. If Music, Safari or another mpv is +> playing, the media keys go there instead. -## How it works +## Configuration -``` -beefy_player/ - app.py Textual app: layout, keys, orchestration - models.py Song + Station, provider-neutral - config.py XDG paths, settings, BEEFY_* env helpers - providers/ - base.py RadioProvider interface + HTTP/playlist toolkit - wordmark.py the half-block font every hero is drawn with - somafm/ the SomaFM catalogue (API, hero, bundled fallback list) - radiobrowser/ the community directory (300 stations, no metadata) - radioparadise/ five curated mixes with real track metadata - tilderadio/ the tilderadio catalogue (one station, hero, schedule) - bassdrive/ Bassdrive (Icecast status, show schedule, rolling history) - community/ one module per community station, on a shared base - player.py mpv over a Unix-socket JSON IPC channel - spectrum.py analyser (parec / Core Audio tap / ffmpeg / sim) - macos_tap.py macOS output capture (ctypes + Core Audio) - mediasession.py platform-neutral desktop media face - mpris.py dbus-next MPRIS service - artwork.py Kitty TGP / iTerm2 images with an ANSI mosaic fallback - bandcamp.py "is this track on Bandcamp?" lookups - widgets/ the Textual widgets (schedule pane, catalogue picker, …) +Settings live in `~/.config/beefy-player/config.json` (favourites, volume, +provider and the last station tuned in each catalogue). Cached stations, +schedules and artwork live in `~/.cache/beefy-player/`. -tools/ - ansi_art.py logo PNG -> half-block ANSI hero (regenerates tilderadio's) -``` +These stay in the usual XDG locations on every platform, macOS included; +`XDG_CONFIG_HOME` and `XDG_CACHE_HOME` are honoured when set. -- `beefy_player/providers/base.py` — the provider interface and the shared - toolkit: `httpx` client factory plus `.pls`/`.m3u` parsing and resolution - (cached for 8 hours), so providers only describe their own catalogue. -- `beefy_player/providers/somafm/api.py` — async `httpx` client for: - - `channels.json` → stations, genres, listeners, `.pls` playlist URLs - - `.pls` → resolved direct icecast URLs (cached 8h) - - `songs/.json` → last played tracks (cached on disk) - Playlist and channel caching means the UI opens instantly and still works - offline. -- `beefy_player/providers/tilderadio/api.py` — the same idea for a - single-station catalogue: `/api/now/` carries the station, the live DJ, the - listener count and both `now_playing` and `history[]`, while AzuraCast's - mount list contributes the bitrates (320k mp3, 192k mp3/ogg). Station and - history caches are per catalogue, so the two providers never overwrite each - other's files. -- `beefy_player/app.py` — holds one provider and one `Player`, ticks at 20 Hz, - and hands the widgets plain `Station`/`Song`/`Broadcast` objects. `p` swaps - the provider in place: the old stream is stopped, workers for the previous - catalogue are cancelled, the hero height and left pane follow the new - provider, and the station list (or schedule) reloads from cache first. -- `beefy_player/providers/bassdrive/api.py` — Icecast's keyless - `status-json.xsl` (mounts, bitrates, listeners, `display-title`) plus the - homepage schedule grid, whose slots carry absolute `` - values. A memoised fetch and a lock keep a cold start to one homepage - download; the display-title's show is matched against the running slot for - "live from ". -- `beefy_player/providers/community/` — the community stations: `base.py` reads - Icecast or radio.co status, keeps the rolling history and turns a running - schedule slot into "what is on"; each station module is data. `rinse.py` - shows the multi-station case and `dublab.py` the schedule case. -- `beefy_player/providers/wordmark.py` — the half-block capitals behind every - hero, with per-character style spans. -- `beefy_player/widgets/schedule.py` — the left pane for single-stream - catalogues: the live slot with its `since`/`until` window, then upcoming - shows grouped by local day, with `… +N more` when the pane runs out. -- `beefy_player/player.py` — mpv over a Unix-socket JSON IPC channel. Observes - `pause`/`volume`, handles mirror failover on `end-file`, auto-restarts a dead - engine, and uses `SIGSTOP`/`SIGCONT` when falling back to `ffplay`. On macOS - it also asks mpv to publish Now Playing and maps the media keys onto - `script-message`s that come back over the same IPC socket. -- `beefy_player/spectrum.py` — analyser (44.1 kHz mono s16le from `parec`, or - the device's native float32 rate from the macOS tap). Prefers capturing the - real output: `parec` (PulseAudio/PipeWire monitor) on Linux, or a native - Core Audio process tap on macOS (SYNC, see `macos_tap.py`). Otherwise a - muted low-latency `ffmpeg` decode of the stream (FFT). Hann-windowed - 2048-point FFT, log band grouping, auto-gain, fast attack/decay smoothing, - peak hold and waveform downsampling. -- `beefy_player/macos_tap.py` — the macOS output capture, in pure `ctypes` - against Core Audio: `CATapDescription` → `AudioHardwareCreateProcessTap` → - private aggregate device → IOProc. No driver, no helper binary, no ffmpeg. -- `beefy_player/mediasession.py` — picks the desktop media backend (MPRIS on - Linux, Now Playing on macOS) so the app has one platform-neutral interface. -- `beefy_player/mpris.py` — `dbus-next` MPRIS service in its own thread with a - thread-safe command queue back into the UI (only loaded on D-Bus platforms). -- `beefy_player/bandcamp.py` — searches Bandcamp's public autocomplete API for - the now-playing track (artist + track first, then artist + album, then the - album page) and returns a track URL when there is a match. -- `beefy_player/widgets/` — Textual widgets rendered with native ANSI colours; - backgrounds are `ansi_default` (`SGR 49`), so transparent terminals stay - transparent. +## Troubleshooting -### Files - -``` -~/.config/beefy-player/config.json favourites, volume, provider, last stations -~/.cache/beefy-player/channels.json last good SomaFM station list -~/.cache/beefy-player/channels-.json last good list, other catalogues -~/.cache/beefy-player/schedule-.json last good broadcast schedule -~/.cache/beefy-player/playlists.json resolved stream URLs -~/.cache/beefy-player/songs/.json SomaFM song history snapshots -~/.cache/beefy-player/songs/

-.json history for every other catalogue -~/.cache/beefy-player/artwork/ cached album/channel artwork -``` - -`last_stations` remembers which station each catalogue was tuned to, so -switching back with `p` lands on the same channel. - -These stay in the XDG locations on every platform (macOS included), so the -same dotfiles/config work everywhere; `XDG_CONFIG_HOME`, `XDG_CACHE_HOME` and -`XDG_RUNTIME_DIR` are honoured when set. The app started life as -`somafm-tui`: on first run a `~/.config/somafm-tui/config.json` is copied over -so favourites and volume survive the rename. - -## Notes / troubleshooting - -- **Artwork shows the mosaic?** Real images are auto-enabled for Kitty, - iTerm2 and WezTerm. Force a protocol with `BEEFY_IMAGES=tgp` (Kitty/TGP) or - `BEEFY_IMAGES=iterm2` (iTerm2/WezTerm); `BEEFY_IMAGES=0` forces the ANSI - mosaic. iTerm2 on macOS is detected from `TERM_PROGRAM`/`LC_TERMINAL`, and - empty or `0` values in `TMUX`/`ZELLIJ`/`STY` (exported by some wrappers) are - ignored so they cannot downgrade it. Kitty inside tmux is attempted through - tmux passthrough (enable `set -g allow-passthrough on`); iTerm2/WezTerm - inside tmux default to the mosaic. Images are cached under - `~/.cache/beefy-player/artwork`. -- **Clock shows UTC?** The clock and song history use the system zone from - `/etc/localtime`; a non-UTC `TZ` overrides it. A container or dev shell can - inherit a build-time `TZ=UTC`, which the app deliberately ignores; bind the - host's `/etc/localtime` or set e.g. `TZ=Europe/Berlin` in the shell that - launches the app. -- **MPRIS says off/error?** No session bus. Run from your desktop session - (the shell profile picks up `$XDG_RUNTIME_DIR/bus`). `dbus-run-session -- uv - run beefy-player` starts a private bus for testing, but desktop widgets won't - see it. macOS does not use MPRIS at all — see “Now Playing” above. -- **No audio?** `beefy-player` needs `mpv` (preferred) or `ffplay`. Without - either, the UI still runs and tells you. Extra mpv args can be injected with - `BEEFY_MPV_ARGS` (e.g. `BEEFY_MPV_ARGS='--audio-device=...'`, which is the - macOS way to choose an output device). -- **Spectrum says `FFT` instead of `SYNC`?** The monitor is unavailable or - failed, or you forced the stream tap. The bars are still real audio, but the - second stream can drift relative to playback. Use `--spectrum-source - monitor` to force sync, or `BEEFY_SPECTRUM_DEVICE=` on Linux to pick - a specific monitor. `SIM` means the bars are synthesized. -- **Diagnose artwork:** `uv run beefy-player --diagnose-images` prints the - detected protocol, widget class, terminal env, dependency versions and cache - path. If `protocol` is `None`, force it with `BEEFY_IMAGES=tgp` (Kitty) or - `BEEFY_IMAGES=iterm2` (iTerm2/WezTerm). -- **`SYNC` but the bars are flat?** mpv may be playing to a different sink (or - directly to ALSA). Point `BEEFY_SPECTRUM_DEVICE` at that sink's monitor, or - use `--spectrum-source stream`. +- **Artwork shows the mosaic?** Real images are auto-enabled for Kitty, iTerm2 + and WezTerm. Force a protocol with `BEEFY_IMAGES=tgp` (Kitty) or + `BEEFY_IMAGES=iterm2` (iTerm2/WezTerm); `BEEFY_IMAGES=0` forces the mosaic. + `--diagnose-images` prints what was detected. +- **No audio?** `mpv` is preferred, `ffplay` works as a fallback. Without + either, the UI still runs and tells you. Extra mpv arguments can be injected + with `BEEFY_MPV_ARGS`, e.g. `BEEFY_MPV_ARGS='--audio-device=...'`. +- **Spectrum says `FFT` instead of `SYNC`?** Output capture is unavailable or + failed, so the analyser is decoding the stream instead; the bars are still + real audio but can drift. Force sync with `--spectrum-source monitor`, or + pick a specific monitor with `BEEFY_SPECTRUM_DEVICE` on Linux. `SIM` means the + bars are synthesized. - **macOS: spectrum stays `FFT`?** Grant your terminal **System Audio - Recording** permission (*System Settings → Privacy & Security → System Audio - Recording*), then restart the app; the status bar explains when the tap is - silent because of this. Also check that mpv is playing to the default output - device, since that is what the tap records. -- **macOS: media keys do nothing?** Another app currently owns the Now Playing - session, or the app was started with `--no-mpris`, or the audio backend fell - back to `ffplay` (mpv is required for Now Playing). The status bar shows - `nowplaying on` when the integration is armed. -- **macOS: Control Center shows only a title?** mpv publishes the stream's live - “Artist - Title” as the Now Playing title; artist/album fields and artwork are - not filled in by macOS for plain audio streams. The TUI itself always shows - the full metadata and album art. -- **Bandwidth** is unchanged in SYNC mode (the monitor capture creates no - second stream). The FFT fallback opens one extra stream; if you are on a - metered link, use `--spectrum-source monitor` (or `--no-audio` for a UI-only - demo). -- SomaFM is listener-supported: if you enjoy it, [donate](https://somafm.com/support/). -- tilderadio is a volunteer-run community station: [drop by](https://tilderadio.org), - or show up on the [schedule](https://tilderadio.org/schedule/) with a set. -- Bassdrive is free and listener-supported too: [bassdrive.com](https://bassdrive.com) - has the shows, archives and the shop that keeps the relays running. + Recording** permission under *System Settings → Privacy & Security → System + Audio Recording*, then restart the app. Also check that mpv is playing to the + default output device, since that is what the analyser records. +- **macOS: media keys do nothing?** Another app owns the Now Playing session, + the app was started with `--no-mpris`, or audio fell back to `ffplay` (mpv is + required for Now Playing). +- **MPRIS says off?** There is no session bus. Run from your desktop session, + or use `dbus-run-session` for a private bus. macOS does not use MPRIS. -## NixOS (optional aside) - -None of the above needs Nix. If you *are* on NixOS, `shell.nix` wraps the same -`uv sync` / `uv run` workflow in an FHS environment, because uv installs -manylinux wheels such as NumPy that expect `libstdc++.so.6` in the usual -`/usr/lib` paths: +## Development ```bash -nix-shell # enter the FHS dev shell -uv sync -uv run beefy-player +uv sync # create the environment +uv run pytest # test suite +uvx ruff check . # lint ``` -- If you see `libstdc++.so.6: cannot open shared object file`, make sure you - are inside `nix-shell`, then recreate the venv once with - `rm -rf .venv && uv sync`. -- `nix-shell --run "..."` does not work with FHS `shell.nix` files; enter the - shell interactively and run commands there. -- FHS shells need unprivileged user namespaces. `bwrap: No permissions to - create a new namespace` means they are disabled (for example in a locked-down - container); run the app on the host instead. +Each catalogue is a self-contained provider module under +`beefy_player/providers/`; `base.py` documents the interface. `tools/ansi_art.py` +converts a PNG logo into half-block ANSI art. On NixOS, `shell.nix` provides an +FHS shell for the same `uv sync` / `uv run` workflow. ## Licence