docs(readme): rewrite for public release
This commit is contained in:
1 parent
638cc9eddb
commit
03297e8751
1 file changed
+127
-530
@@ -3,346 +3,76 @@
|
|||||||

|

|
||||||
|
|
||||||
A flashy terminal player for **internet radio** — a live spectrum analyser,
|
A flashy terminal player for **internet radio** — a live spectrum analyser,
|
||||||
song history, and full desktop media integration, drawn with your terminal's
|
song history and album art, with desktop media integration on Linux and macOS.
|
||||||
own ANSI palette on the terminal's default background so it fits any theme
|
It draws with your terminal's own ANSI palette on the terminal's default
|
||||||
(including transparent terminals).
|
background, so it fits any theme, including transparent terminals.
|
||||||
|
|
||||||
The station catalogue is pluggable. Beefy Player ships with thirteen catalogues
|
Thirteen station catalogues ship today, from SomaFM's curated channels to the
|
||||||
— **[SomaFM](https://somafm.com)** (≈50 curated channels),
|
62,000-station Radio Browser directory, loaded as you scroll and searched live
|
||||||
**[Radio Paradise](https://radioparadise.com)** (five mixes), the community
|
as you type. Press `p` to switch catalogue without restarting.
|
||||||
stations **[tilderadio](https://tilderadio.org)**,
|
|
||||||
**[Bassdrive](https://bassdrive.com)**, **[dublab](https://dublab.com)**,
|
|
||||||
**[Rinse FM](https://rinse.fm)**, **[Refuge Worldwide](https://refugeworldwide.com)**,
|
|
||||||
**[LYL Radio](https://lyl.live)**, **[The Lot Radio](https://thelotradio.com)**,
|
|
||||||
**[Cashmere Radio](https://cashmereradio.com)**,
|
|
||||||
**[Datafruits](https://datafruits.fm)** and **[aNONradio](https://anonradio.net)**,
|
|
||||||
plus the **[Radio Browser](https://www.radio-browser.info)** directory
|
|
||||||
(300 stations) — and the UI never talks to a station catalogue directly: it asks a
|
|
||||||
[provider](#providers) for stations, song history and stream URLs. Press `p` to
|
|
||||||
switch catalogue without restarting. Adding another internet-radio catalogue is
|
|
||||||
a self-contained module, not a rewrite.
|
|
||||||
|
|
||||||
It runs the same way on **Linux** (PulseAudio/PipeWire) and **macOS**
|
|
||||||
(CoreAudio, iTerm2/Kitty/WezTerm): MPRIS on the desktop bus where that exists,
|
|
||||||
macOS Now Playing (Control Center and the media keys) where it does not.
|
|
||||||
|
|
||||||
No existing radio client is used: the station list, playlists, song history,
|
|
||||||
audio control, spectrum visualiser and media-session integration are all
|
|
||||||
implemented here from public endpoints and open protocols.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Live spectrum analyser** — 72 log-spaced bands with peak caps, a moving
|
- **Live spectrum analyser** — 72 log-spaced bands with peak caps, a palette
|
||||||
gradient taken from your terminal's ANSI palette and a symmetric mirrored
|
gradient and a mirrored spectrum. Six modes (`v`): mirrored bars, plain bars,
|
||||||
spectrum. Six modes (`v`): mirrored bars, plain bars, oscilloscope,
|
oscilloscope, scrolling waterfall, radial spectrum and a high-resolution
|
||||||
scrolling waterfall spectrogram, a radial spectrum wrapped around the
|
braille oscilloscope.
|
||||||
centre, and a high-resolution braille oscilloscope (2×4 dots per cell).
|
- **Synced to what you hear** — the analyser listens to your actual audio
|
||||||
- **Synced spectrum, not a guess** — by default the analyser records your
|
output by default, so the bars follow the music. If that is unavailable it
|
||||||
actual audio output, so the bars follow what you hear (`SYNC`): the
|
falls back to decoding the stream, then to a simulated display.
|
||||||
PulseAudio/PipeWire monitor via `parec` on Linux, and a native Core Audio
|
- **Now playing and history** — artist, title, album, DJ, listeners, bitrate
|
||||||
process tap on macOS (no driver, no BlackHole, nothing to install). Without
|
and "played N minutes ago", plus the full recent history. A clickable
|
||||||
an output capture it falls back to a muted second `ffmpeg` decode of the
|
**Bandcamp ↗** link appears when the current track can be found there.
|
||||||
stream (`FFT`, can drift), then to a synthesized display (`SIM`).
|
- **Real album art** — native images in Kitty, iTerm2 and WezTerm; an
|
||||||
- **Real artwork on iTerm2, Kitty and WezTerm** — Kitty uses the kitty
|
ANSI-shaded mosaic in every other terminal.
|
||||||
graphics protocol; iTerm2 (including on macOS) and WezTerm use their native
|
- **Thirteen station catalogues** — SomaFM (≈50 channels), Radio Paradise,
|
||||||
iTerm2 inline-image protocol. Images occupy exactly the ON AIR art cell box
|
tilderadio, Bassdrive, dublab, Rinse FM, Refuge Worldwide, LYL Radio, The Lot
|
||||||
and fall back to the procedural ANSI mosaic for other terminals, failed
|
Radio, Cashmere Radio, Datafruits, aNONradio and the Radio Browser directory
|
||||||
loads, or blocked graphics passthrough. Images are cached in
|
(loaded as you scroll in popularity order, with live search across all
|
||||||
`~/.cache/beefy-player/artwork`, letterboxed rather than cropped, and an
|
62,000).
|
||||||
image that carries an alpha channel is composited onto black — a transparent
|
- **Favourites, search and schedules** — favourites float to the top and are
|
||||||
PNG drawn over the mosaic reads as noise behind a station logo.
|
remembered between runs, including directory stations outside the loaded
|
||||||
- **Now playing** — artist, title, album, DJ, listeners, format/bitrate and
|
page; search by title, genre or description, with live server-side search
|
||||||
"played N minutes ago", with procedural ANSI-shaded album art that changes
|
across the Radio Browser directory; single-stream stations show their
|
||||||
per track. When the current track can be found on Bandcamp, a clickable
|
broadcast schedule instead of a one-item list.
|
||||||
**Bandcamp ↗** link appears below it; otherwise the line stays hidden.
|
- **Desktop media integration** — MPRIS on Linux (GNOME/KDE widgets, lock
|
||||||
Catalogues can opt out with `bandcamp = False` (tilderadio does), and a
|
screens, `playerctl` and the media keys) and Now Playing on macOS (Control
|
||||||
provider can fill the `Station.presenter` line instead — tilderadio shows
|
Center and the media keys).
|
||||||
`auto dj · library rotation` between live shows and `dj <name>` during one.
|
- **Sixteen palettes, theme-aware** — every colour is native ANSI against the
|
||||||
- **Last played** — the full recent history, newest first.
|
terminal's default background, so transparent terminals stay transparent and
|
||||||
- **Every station** — with SomaFM that is the whole `channels.json` roster
|
the TUI follows your colour scheme. Press `c` to cycle palettes.
|
||||||
(≈50 channels), live listener counts and genre search; tilderadio is a
|
- **Graceful offline** — cached stations and schedules, a bundled fallback
|
||||||
single-station catalogue, and the list simply holds one. Long lists get a grey
|
list, automatic mirror failover and `ffplay` when `mpv` is missing.
|
||||||
thumb down the right edge (no rail) and a `12/51` position in the pane title;
|
|
||||||
`pgup`/`pgdn` move a screen at a time.
|
|
||||||
- **Favourites** that float to the top, remembered between runs.
|
|
||||||
- **Pluggable catalogues** — one `providers` module per radio network, with a
|
|
||||||
`p` picker in the UI, `--provider` on the command line, and the choice
|
|
||||||
remembered per catalogue (including the last station you tuned). Switching to
|
|
||||||
a single-station catalogue tunes it straight away; a multi-station one waits
|
|
||||||
for `enter`.
|
|
||||||
- **A hero per catalogue** — the top-left art is the catalogue's own name in a
|
|
||||||
shared half-block font: SomaFM in the spectrum gradient, tilderadio in the
|
|
||||||
terminal's default colour, Bassdrive in its two blues. Every hero is three
|
|
||||||
rows tall, so the header keeps one height.
|
|
||||||
- **Schedule pane** — a single-stream catalogue gets a broadcast schedule
|
|
||||||
instead of a list of one station: who is on air now, since/until when, and
|
|
||||||
the upcoming shows grouped by day.
|
|
||||||
- **Desktop media integration** — on Linux an
|
|
||||||
`org.mpris.MediaPlayer2.beefyplayer.instance<pid>` player exposes
|
|
||||||
`PlaybackStatus`, `Metadata` (title/artist/album/artUrl), `Volume`, and
|
|
||||||
`Play`/`Pause`/`PlayPause`/`Stop`/`Next`/`Previous`, so GNOME/KDE widgets,
|
|
||||||
lock screens, `playerctl` and the media keys pick it up. On macOS the audio
|
|
||||||
engine (mpv) publishes to the system **Now Playing** session, so Control
|
|
||||||
Center, the menu bar and the keyboard media keys drive the app the same way.
|
|
||||||
- **Sixteen palettes** — every colour comes from one ladder of ANSI names per
|
|
||||||
theme: the spectrum analyser, the album-art mosaic, the provider wordmarks
|
|
||||||
and the pane corners all draw from it. `c` cycles spectrum, rainbow, ocean,
|
|
||||||
ember, mint, sunset, violet, acid, neon, sand, blood, forest, ice, steel,
|
|
||||||
candy and mono; the choice is remembered.
|
|
||||||
- **Theme-aware** — every accent is emitted as native ANSI (not RGB), and the
|
|
||||||
background is the terminal default (`SGR 49`), so transparent terminals stay
|
|
||||||
transparent and the TUI adopts your shell/terminal colour scheme.
|
|
||||||
- **Graceful degradation** — works with cached data offline, falls back to a
|
|
||||||
bundled station list, reconnects across ICE mirrors automatically, and uses
|
|
||||||
`ffplay` if `mpv` is missing.
|
|
||||||
|
|
||||||
## Requirements
|
## Installation
|
||||||
|
|
||||||
`beefy-player` is a normal Python application: there is nothing to compile and
|
## Usage
|
||||||
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):
|
|
||||||
|
|
||||||
```bash
|
```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/<station>.json` history, the PLS
|
|
||||||
layout and the guessed icecast mirrors all live in
|
|
||||||
`beefy_player/providers/somafm/`. Nothing outside that package imports it; the
|
|
||||||
app only ever holds a `RadioProvider`.
|
|
||||||
|
|
||||||
Thirteen catalogues ship today:
|
|
||||||
|
|
||||||
| slug | catalogue | stations | what is on | relays |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| `somafm` | [SomaFM](https://somafm.com) | `channels.json` (≈50) | `songs/<station>.json` | `.pls` → icecast mirrors |
|
|
||||||
| `radioparadise` | [Radio Paradise](https://radioparadise.com) | five mixes | `api/now_playing` (artist, album, cover) | FLAC / AAC / MP3 icecast |
|
|
||||||
| `tilderadio` | [tilderadio](https://tilderadio.org) | 1 + schedule | `/api/now/` | AzuraCast mounts |
|
|
||||||
| `bassdrive` | [Bassdrive](https://bassdrive.com) | 1 + schedule | icecast `display-title` (the show) | three mounts + mirrors |
|
|
||||||
| `dublab` | [dublab](https://dublab.com) | 1 + schedule | dublab's calendar | Airtime mp3 |
|
|
||||||
| `rinse` | [Rinse FM](https://rinse.fm) | 3 | — | proxy mounts (aac+) |
|
|
||||||
| `refuge` | [Refuge Worldwide](https://refugeworldwide.com) | 1 | `public.radio.co` status | radio.co mp3 |
|
|
||||||
| `lyl` | [LYL Radio](https://lyl.live) | 1 | — | icecast mp3 |
|
|
||||||
| `lot` | [The Lot Radio](https://thelotradio.com) | 1 | — | HLS |
|
|
||||||
| `cashmere` | [Cashmere Radio](https://cashmereradio.com) | 1 | — | Airtime ogg/mp3 |
|
|
||||||
| `datafruits` | [Datafruits](https://datafruits.fm) | 1 | streampusher icecast | relay mp3 |
|
|
||||||
| `anonradio` | [aNONradio](https://anonradio.net) | 1 | icecast title | icecast mp3 |
|
|
||||||
| `radiobrowser` | [Radio Browser](https://www.radio-browser.info) | 300 (directory) | — | whatever each station publishes |
|
|
||||||
|
|
||||||
They are deliberately different shapes: fifty channels with PLS playlists, a
|
|
||||||
single AzuraCast stream that carries its own history, and an Icecast relay whose
|
|
||||||
"now playing" is the *show* rather than the track. That is the seam working.
|
|
||||||
|
|
||||||
The single-stream catalogues also use the optional capabilities: where a
|
|
||||||
schedule exists (`tilderadio`, `bassdrive`, `dublab`) the left pane is that
|
|
||||||
schedule rather than a list containing one station, and none of them do
|
|
||||||
Bandcamp lookups. Bassdrive is the extreme case — Icecast reports one title and
|
|
||||||
keeps no history, so the provider maps the title to `Station.dj`/`presenter`
|
|
||||||
and keeps a rolling record of the shows it has heard.
|
|
||||||
|
|
||||||
The community stations share one base class,
|
|
||||||
`providers/community/base.py`, which knows how to read an Icecast
|
|
||||||
`status-json.xsl`, a `public.radio.co` status, a scheduled show, or nothing at
|
|
||||||
all; each station is then ten lines of data (relays, wordmark, tagline). Where
|
|
||||||
a relay publishes no metadata (Rinse, LYL, The Lot, Cashmere) the app simply
|
|
||||||
plays it and says so, rather than inventing a track.
|
|
||||||
|
|
||||||
`radiobrowser` is the directory case: three hundred of the most-clicked working
|
|
||||||
stations from the community database, keyless and public-domain, with no
|
|
||||||
per-station history at all — breadth rather than depth, and the one catalogue
|
|
||||||
where ON AIR legitimately has nothing to say.
|
|
||||||
|
|
||||||
### Header wordmarks
|
|
||||||
|
|
||||||
All three heroes come from one font, `beefy_player/providers/wordmark.py`:
|
|
||||||
four-column capitals six pixels tall, emitted as three rows of half blocks
|
|
||||||
(`▀`/`▄`/`█`) — the shape of the original SomaFM logo. A provider's `hero.py`
|
|
||||||
is one line of data:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# beefy_player/providers/bassdrive/hero.py
|
|
||||||
HERO = render("BASSDRIVE", ["bright_cyan"] * 4 + ["cyan"] * 5)
|
|
||||||
```
|
|
||||||
|
|
||||||
`render()` takes one optional style per character and groups equal neighbours
|
|
||||||
into markup spans; with no styles the wordmark inherits the terminal's default
|
|
||||||
foreground (tilderadio). Add a glyph to `GLYPHS` when a catalogue needs a letter
|
|
||||||
the font does not have yet — `▀█▀` is the I, one pixel thick on purpose.
|
|
||||||
|
|
||||||
Image logos are a different problem: `tools/ansi_art.py` converts a PNG into
|
|
||||||
half-block art (`--key-white` for logos on white, `--preview out.png` to see the
|
|
||||||
result). No shipped catalogue needs it today, but it is the tool to reach for
|
|
||||||
when a station's identity is a picture rather than a word.
|
|
||||||
|
|
||||||
Adding a catalogue:
|
|
||||||
|
|
||||||
1. create `beefy_player/providers/<slug>/` with a `RadioProvider` subclass,
|
|
||||||
2. register one instance in `beefy_player/providers/__init__.py`,
|
|
||||||
3. run it with `beefy-player --provider <slug>`, press `p` in the UI, or set
|
|
||||||
`"provider"` in `~/.config/beefy-player/config.json`.
|
|
||||||
|
|
||||||
`RadioProvider.stream_candidates()` already implements the common strategy —
|
|
||||||
resolve the station's playlists (with an on-disk cache), then try
|
|
||||||
`fallback_urls()` — so most providers only have to supply the catalogue,
|
|
||||||
history and mirror URLs.
|
|
||||||
|
|
||||||
## Run
|
|
||||||
|
|
||||||
From the repository root, sync the environment and start the app:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
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.
|
The UI wants roughly **84×24**; it also works smaller, just less pretty.
|
||||||
|
|
||||||
## Keys
|
| flag | what it does |
|
||||||
|
| --- | --- |
|
||||||
|
| `--provider <slug>` | pick a station catalogue |
|
||||||
|
| `--station <id>` | pick a station (see `--list`) |
|
||||||
|
| `--play` | tune the last station right away |
|
||||||
|
| `--list` | print station ids for the current catalogue |
|
||||||
|
| `--volume <0-100>` | set the initial volume |
|
||||||
|
| `--offline` | no network; use cached and bundled stations |
|
||||||
|
| `--no-audio` | UI demo without `mpv` / `ffmpeg` |
|
||||||
|
| `--no-mpris` | no MPRIS / Now Playing, no media keys |
|
||||||
|
| `--spectrum-source monitor\|stream` | force synced output capture or the stream tap |
|
||||||
|
|
||||||
|
Environment variables are prefixed `BEEFY_`: `BEEFY_IMAGES` (`tgp`, `iterm2`
|
||||||
|
or `0`), `BEEFY_MPV_ARGS` and `BEEFY_SPECTRUM_DEVICE`.
|
||||||
|
|
||||||
|
### Keys
|
||||||
|
|
||||||
| key | action |
|
| key | action |
|
||||||
| -------------- | --------------------------------------------- |
|
| --- | --- |
|
||||||
| `↑` / `↓`, `j` / `k` | move through stations |
|
| `↑` / `↓`, `j` / `k` | move through stations |
|
||||||
| `pgup` / `pgdn` | page through stations |
|
| `pgup` / `pgdn` | page through stations |
|
||||||
| `enter` | tune in to the selected station |
|
| `enter` | tune in to the selected station |
|
||||||
@@ -353,20 +83,41 @@ The UI wants roughly **84×24**; it also works smaller, just less pretty.
|
|||||||
| `m` | mute |
|
| `m` | mute |
|
||||||
| `f` | favourite (favourites sort to the top) |
|
| `f` | favourite (favourites sort to the top) |
|
||||||
| `/` | filter by title, genre, description |
|
| `/` | filter by title, genre, description |
|
||||||
| `esc` | clear filter / close search |
|
| `esc` | close the search box; press again to clear the filter |
|
||||||
| `v` | visualiser: mirror → bars → wave → waterfall → radial → braille |
|
| `v` | visualiser: mirror → bars → wave → waterfall → radial → braille |
|
||||||
| `c` | colour palette (16 of them, remembered) |
|
| `c` | colour palette (16 of them, remembered) |
|
||||||
| `r` | refresh stations, schedule + song history |
|
| `r` | refresh stations, schedule and song history |
|
||||||
| `p` | switch station catalogue (SomaFM / tilderadio)|
|
| `p` | switch station catalogue |
|
||||||
| `g` / `G` | top / bottom |
|
| `g` / `G` | top / bottom |
|
||||||
| `?` | help |
|
| `?` | help |
|
||||||
| `q` | quit |
|
| `q` | quit |
|
||||||
|
|
||||||
## Desktop media widgets (MPRIS / Now Playing)
|
## Station catalogues
|
||||||
|
|
||||||
**Linux** — while playing, the app exports
|
| catalogue | slug | stations |
|
||||||
`org.mpris.MediaPlayer2.beefyplayer.instance<pid>` on the **session bus**. Check
|
| --- | --- | --- |
|
||||||
it:
|
| [SomaFM](https://somafm.com) | `somafm` | ≈50 curated channels |
|
||||||
|
| [Radio Paradise](https://radioparadise.com) | `radioparadise` | 5 mixes |
|
||||||
|
| [tilderadio](https://tilderadio.org) | `tilderadio` | 1 + schedule |
|
||||||
|
| [Bassdrive](https://bassdrive.com) | `bassdrive` | 1 + schedule |
|
||||||
|
| [dublab](https://dublab.com) | `dublab` | 1 + schedule |
|
||||||
|
| [Rinse FM](https://rinse.fm) | `rinse` | 3 |
|
||||||
|
| [Refuge Worldwide](https://refugeworldwide.com) | `refuge` | 1 |
|
||||||
|
| [LYL Radio](https://lyl.live) | `lyl` | 1 |
|
||||||
|
| [The Lot Radio](https://thelotradio.com) | `lot` | 1 |
|
||||||
|
| [Cashmere Radio](https://cashmereradio.com) | `cashmere` | 1 |
|
||||||
|
| [Datafruits](https://datafruits.fm) | `datafruits` | 1 |
|
||||||
|
| [aNONradio](https://anonradio.net) | `anonradio` | 1 |
|
||||||
|
| [Radio Browser](https://www.radio-browser.info) | `radiobrowser` | 62k · paged + live search |
|
||||||
|
|
||||||
|
Pick one with `p` in the UI, `--provider <slug>` on the command line, or by
|
||||||
|
setting `"provider"` in the config file.
|
||||||
|
|
||||||
|
## Desktop media integration
|
||||||
|
|
||||||
|
**Linux** — while playing, the app exposes an
|
||||||
|
`org.mpris.MediaPlayer2.beefyplayer.instance<pid>` player on the session bus,
|
||||||
|
so GNOME/KDE widgets, lock screens, `playerctl` and the media keys pick it up:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
playerctl -l
|
playerctl -l
|
||||||
@@ -374,216 +125,62 @@ playerctl metadata --format '{{artist}} — {{title}} ({{album}})'
|
|||||||
playerctl play-pause
|
playerctl play-pause
|
||||||
```
|
```
|
||||||
|
|
||||||
`mpris:artUrl` is the track's album art when the provider supplies it,
|
If there is no session bus (SSH without `dbus-run-session`, containers, …)
|
||||||
otherwise the channel logo. If there is no session bus (SSH without
|
MPRIS is skipped and the status bar shows `mpris off`.
|
||||||
`dbus-run-session`, containers, …) MPRIS is skipped and the status bar shows
|
|
||||||
`mpris off`.
|
|
||||||
|
|
||||||
**macOS** — mpv owns the system Now Playing session, so Control Center, the
|
**macOS** — mpv publishes to the system Now Playing session, so Control Center,
|
||||||
menu-bar widget and the keyboard media keys (▶︎/⏸, ⏭, ⏮) all target this app
|
the menu bar and the media keys drive the app while it plays. The status bar
|
||||||
while it is playing. mpv publishes the live stream title (the same
|
shows `nowplaying on`. `--no-mpris` (also spelled `--no-media`) turns the media
|
||||||
“Artist - Title” that the station sends with the audio), and the media keys are
|
keys off.
|
||||||
routed back into the TUI, so `⏭`/`⏮` really change station and the panel, the
|
|
||||||
spectrum and the station list stay in sync. The status bar shows
|
|
||||||
`nowplaying on`. There is no D-Bus and no extra dependency involved, and
|
|
||||||
`--no-mpris` (also spelled `--no-media`) turns the media keys off again.
|
|
||||||
|
|
||||||
> macOS shows one Now Playing app at a time. If Music/Safari/another mpv is
|
> macOS shows one Now Playing app at a time. If Music, Safari or another mpv is
|
||||||
> playing, the media keys go there instead — quit it or pause it to hand the
|
> playing, the media keys go there instead.
|
||||||
> keys back.
|
|
||||||
|
|
||||||
## How it works
|
## Configuration
|
||||||
|
|
||||||
```
|
Settings live in `~/.config/beefy-player/config.json` (favourites, volume,
|
||||||
beefy_player/
|
provider and the last station tuned in each catalogue). Cached stations,
|
||||||
app.py Textual app: layout, keys, orchestration
|
schedules and artwork live in `~/.cache/beefy-player/`.
|
||||||
models.py Song + Station, provider-neutral
|
|
||||||
config.py XDG paths, settings, BEEFY_* env helpers
|
|
||||||
providers/
|
|
||||||
base.py RadioProvider interface + HTTP/playlist toolkit
|
|
||||||
wordmark.py the half-block font every hero is drawn with
|
|
||||||
somafm/ the SomaFM catalogue (API, hero, bundled fallback list)
|
|
||||||
radiobrowser/ the community directory (300 stations, no metadata)
|
|
||||||
radioparadise/ five curated mixes with real track metadata
|
|
||||||
tilderadio/ the tilderadio catalogue (one station, hero, schedule)
|
|
||||||
bassdrive/ Bassdrive (Icecast status, show schedule, rolling history)
|
|
||||||
community/ one module per community station, on a shared base
|
|
||||||
player.py mpv over a Unix-socket JSON IPC channel
|
|
||||||
spectrum.py analyser (parec / Core Audio tap / ffmpeg / sim)
|
|
||||||
macos_tap.py macOS output capture (ctypes + Core Audio)
|
|
||||||
mediasession.py platform-neutral desktop media face
|
|
||||||
mpris.py dbus-next MPRIS service
|
|
||||||
artwork.py Kitty TGP / iTerm2 images with an ANSI mosaic fallback
|
|
||||||
bandcamp.py "is this track on Bandcamp?" lookups
|
|
||||||
widgets/ the Textual widgets (schedule pane, catalogue picker, …)
|
|
||||||
|
|
||||||
tools/
|
These stay in the usual XDG locations on every platform, macOS included;
|
||||||
ansi_art.py logo PNG -> half-block ANSI hero (regenerates tilderadio's)
|
`XDG_CONFIG_HOME` and `XDG_CACHE_HOME` are honoured when set.
|
||||||
```
|
|
||||||
|
|
||||||
- `beefy_player/providers/base.py` — the provider interface and the shared
|
## Troubleshooting
|
||||||
toolkit: `httpx` client factory plus `.pls`/`.m3u` parsing and resolution
|
|
||||||
(cached for 8 hours), so providers only describe their own catalogue.
|
|
||||||
- `beefy_player/providers/somafm/api.py` — async `httpx` client for:
|
|
||||||
- `channels.json` → stations, genres, listeners, `.pls` playlist URLs
|
|
||||||
- `<station>.pls` → resolved direct icecast URLs (cached 8h)
|
|
||||||
- `songs/<station>.json` → last played tracks (cached on disk)
|
|
||||||
Playlist and channel caching means the UI opens instantly and still works
|
|
||||||
offline.
|
|
||||||
- `beefy_player/providers/tilderadio/api.py` — the same idea for a
|
|
||||||
single-station catalogue: `/api/now/` carries the station, the live DJ, the
|
|
||||||
listener count and both `now_playing` and `history[]`, while AzuraCast's
|
|
||||||
mount list contributes the bitrates (320k mp3, 192k mp3/ogg). Station and
|
|
||||||
history caches are per catalogue, so the two providers never overwrite each
|
|
||||||
other's files.
|
|
||||||
- `beefy_player/app.py` — holds one provider and one `Player`, ticks at 20 Hz,
|
|
||||||
and hands the widgets plain `Station`/`Song`/`Broadcast` objects. `p` swaps
|
|
||||||
the provider in place: the old stream is stopped, workers for the previous
|
|
||||||
catalogue are cancelled, the hero height and left pane follow the new
|
|
||||||
provider, and the station list (or schedule) reloads from cache first.
|
|
||||||
- `beefy_player/providers/bassdrive/api.py` — Icecast's keyless
|
|
||||||
`status-json.xsl` (mounts, bitrates, listeners, `display-title`) plus the
|
|
||||||
homepage schedule grid, whose slots carry absolute `<local-time datetime>`
|
|
||||||
values. A memoised fetch and a lock keep a cold start to one homepage
|
|
||||||
download; the display-title's show is matched against the running slot for
|
|
||||||
"live from <city>".
|
|
||||||
- `beefy_player/providers/community/` — the community stations: `base.py` reads
|
|
||||||
Icecast or radio.co status, keeps the rolling history and turns a running
|
|
||||||
schedule slot into "what is on"; each station module is data. `rinse.py`
|
|
||||||
shows the multi-station case and `dublab.py` the schedule case.
|
|
||||||
- `beefy_player/providers/wordmark.py` — the half-block capitals behind every
|
|
||||||
hero, with per-character style spans.
|
|
||||||
- `beefy_player/widgets/schedule.py` — the left pane for single-stream
|
|
||||||
catalogues: the live slot with its `since`/`until` window, then upcoming
|
|
||||||
shows grouped by local day, with `… +N more` when the pane runs out.
|
|
||||||
- `beefy_player/player.py` — mpv over a Unix-socket JSON IPC channel. Observes
|
|
||||||
`pause`/`volume`, handles mirror failover on `end-file`, auto-restarts a dead
|
|
||||||
engine, and uses `SIGSTOP`/`SIGCONT` when falling back to `ffplay`. On macOS
|
|
||||||
it also asks mpv to publish Now Playing and maps the media keys onto
|
|
||||||
`script-message`s that come back over the same IPC socket.
|
|
||||||
- `beefy_player/spectrum.py` — analyser (44.1 kHz mono s16le from `parec`, or
|
|
||||||
the device's native float32 rate from the macOS tap). Prefers capturing the
|
|
||||||
real output: `parec` (PulseAudio/PipeWire monitor) on Linux, or a native
|
|
||||||
Core Audio process tap on macOS (SYNC, see `macos_tap.py`). Otherwise a
|
|
||||||
muted low-latency `ffmpeg` decode of the stream (FFT). Hann-windowed
|
|
||||||
2048-point FFT, log band grouping, auto-gain, fast attack/decay smoothing,
|
|
||||||
peak hold and waveform downsampling.
|
|
||||||
- `beefy_player/macos_tap.py` — the macOS output capture, in pure `ctypes`
|
|
||||||
against Core Audio: `CATapDescription` → `AudioHardwareCreateProcessTap` →
|
|
||||||
private aggregate device → IOProc. No driver, no helper binary, no ffmpeg.
|
|
||||||
- `beefy_player/mediasession.py` — picks the desktop media backend (MPRIS on
|
|
||||||
Linux, Now Playing on macOS) so the app has one platform-neutral interface.
|
|
||||||
- `beefy_player/mpris.py` — `dbus-next` MPRIS service in its own thread with a
|
|
||||||
thread-safe command queue back into the UI (only loaded on D-Bus platforms).
|
|
||||||
- `beefy_player/bandcamp.py` — searches Bandcamp's public autocomplete API for
|
|
||||||
the now-playing track (artist + track first, then artist + album, then the
|
|
||||||
album page) and returns a track URL when there is a match.
|
|
||||||
- `beefy_player/widgets/` — Textual widgets rendered with native ANSI colours;
|
|
||||||
backgrounds are `ansi_default` (`SGR 49`), so transparent terminals stay
|
|
||||||
transparent.
|
|
||||||
|
|
||||||
### Files
|
- **Artwork shows the mosaic?** Real images are auto-enabled for Kitty, iTerm2
|
||||||
|
and WezTerm. Force a protocol with `BEEFY_IMAGES=tgp` (Kitty) or
|
||||||
```
|
`BEEFY_IMAGES=iterm2` (iTerm2/WezTerm); `BEEFY_IMAGES=0` forces the mosaic.
|
||||||
~/.config/beefy-player/config.json favourites, volume, provider, last stations
|
`--diagnose-images` prints what was detected.
|
||||||
~/.cache/beefy-player/channels.json last good SomaFM station list
|
- **No audio?** `mpv` is preferred, `ffplay` works as a fallback. Without
|
||||||
~/.cache/beefy-player/channels-<id>.json last good list, other catalogues
|
either, the UI still runs and tells you. Extra mpv arguments can be injected
|
||||||
~/.cache/beefy-player/schedule-<id>.json last good broadcast schedule
|
with `BEEFY_MPV_ARGS`, e.g. `BEEFY_MPV_ARGS='--audio-device=...'`.
|
||||||
~/.cache/beefy-player/playlists.json resolved stream URLs
|
- **Spectrum says `FFT` instead of `SYNC`?** Output capture is unavailable or
|
||||||
~/.cache/beefy-player/songs/<id>.json SomaFM song history snapshots
|
failed, so the analyser is decoding the stream instead; the bars are still
|
||||||
~/.cache/beefy-player/songs/<p>-<id>.json history for every other catalogue
|
real audio but can drift. Force sync with `--spectrum-source monitor`, or
|
||||||
~/.cache/beefy-player/artwork/<sha1> cached album/channel artwork
|
pick a specific monitor with `BEEFY_SPECTRUM_DEVICE` on Linux. `SIM` means the
|
||||||
```
|
bars are synthesized.
|
||||||
|
|
||||||
`last_stations` remembers which station each catalogue was tuned to, so
|
|
||||||
switching back with `p` lands on the same channel.
|
|
||||||
|
|
||||||
These stay in the XDG locations on every platform (macOS included), so the
|
|
||||||
same dotfiles/config work everywhere; `XDG_CONFIG_HOME`, `XDG_CACHE_HOME` and
|
|
||||||
`XDG_RUNTIME_DIR` are honoured when set. The app started life as
|
|
||||||
`somafm-tui`: on first run a `~/.config/somafm-tui/config.json` is copied over
|
|
||||||
so favourites and volume survive the rename.
|
|
||||||
|
|
||||||
## Notes / troubleshooting
|
|
||||||
|
|
||||||
- **Artwork shows the mosaic?** Real images are auto-enabled for Kitty,
|
|
||||||
iTerm2 and WezTerm. Force a protocol with `BEEFY_IMAGES=tgp` (Kitty/TGP) or
|
|
||||||
`BEEFY_IMAGES=iterm2` (iTerm2/WezTerm); `BEEFY_IMAGES=0` forces the ANSI
|
|
||||||
mosaic. iTerm2 on macOS is detected from `TERM_PROGRAM`/`LC_TERMINAL`, and
|
|
||||||
empty or `0` values in `TMUX`/`ZELLIJ`/`STY` (exported by some wrappers) are
|
|
||||||
ignored so they cannot downgrade it. Kitty inside tmux is attempted through
|
|
||||||
tmux passthrough (enable `set -g allow-passthrough on`); iTerm2/WezTerm
|
|
||||||
inside tmux default to the mosaic. Images are cached under
|
|
||||||
`~/.cache/beefy-player/artwork`.
|
|
||||||
- **Clock shows UTC?** The clock and song history use the system zone from
|
|
||||||
`/etc/localtime`; a non-UTC `TZ` overrides it. A container or dev shell can
|
|
||||||
inherit a build-time `TZ=UTC`, which the app deliberately ignores; bind the
|
|
||||||
host's `/etc/localtime` or set e.g. `TZ=Europe/Berlin` in the shell that
|
|
||||||
launches the app.
|
|
||||||
- **MPRIS says off/error?** No session bus. Run from your desktop session
|
|
||||||
(the shell profile picks up `$XDG_RUNTIME_DIR/bus`). `dbus-run-session -- uv
|
|
||||||
run beefy-player` starts a private bus for testing, but desktop widgets won't
|
|
||||||
see it. macOS does not use MPRIS at all — see “Now Playing” above.
|
|
||||||
- **No audio?** `beefy-player` needs `mpv` (preferred) or `ffplay`. Without
|
|
||||||
either, the UI still runs and tells you. Extra mpv args can be injected with
|
|
||||||
`BEEFY_MPV_ARGS` (e.g. `BEEFY_MPV_ARGS='--audio-device=...'`, which is the
|
|
||||||
macOS way to choose an output device).
|
|
||||||
- **Spectrum says `FFT` instead of `SYNC`?** The monitor is unavailable or
|
|
||||||
failed, or you forced the stream tap. The bars are still real audio, but the
|
|
||||||
second stream can drift relative to playback. Use `--spectrum-source
|
|
||||||
monitor` to force sync, or `BEEFY_SPECTRUM_DEVICE=<source>` on Linux to pick
|
|
||||||
a specific monitor. `SIM` means the bars are synthesized.
|
|
||||||
- **Diagnose artwork:** `uv run beefy-player --diagnose-images` prints the
|
|
||||||
detected protocol, widget class, terminal env, dependency versions and cache
|
|
||||||
path. If `protocol` is `None`, force it with `BEEFY_IMAGES=tgp` (Kitty) or
|
|
||||||
`BEEFY_IMAGES=iterm2` (iTerm2/WezTerm).
|
|
||||||
- **`SYNC` but the bars are flat?** mpv may be playing to a different sink (or
|
|
||||||
directly to ALSA). Point `BEEFY_SPECTRUM_DEVICE` at that sink's monitor, or
|
|
||||||
use `--spectrum-source stream`.
|
|
||||||
- **macOS: spectrum stays `FFT`?** Grant your terminal **System Audio
|
- **macOS: spectrum stays `FFT`?** Grant your terminal **System Audio
|
||||||
Recording** permission (*System Settings → Privacy & Security → System Audio
|
Recording** permission under *System Settings → Privacy & Security → System
|
||||||
Recording*), then restart the app; the status bar explains when the tap is
|
Audio Recording*, then restart the app. Also check that mpv is playing to the
|
||||||
silent because of this. Also check that mpv is playing to the default output
|
default output device, since that is what the analyser records.
|
||||||
device, since that is what the tap records.
|
- **macOS: media keys do nothing?** Another app owns the Now Playing session,
|
||||||
- **macOS: media keys do nothing?** Another app currently owns the Now Playing
|
the app was started with `--no-mpris`, or audio fell back to `ffplay` (mpv is
|
||||||
session, or the app was started with `--no-mpris`, or the audio backend fell
|
required for Now Playing).
|
||||||
back to `ffplay` (mpv is required for Now Playing). The status bar shows
|
- **MPRIS says off?** There is no session bus. Run from your desktop session,
|
||||||
`nowplaying on` when the integration is armed.
|
or use `dbus-run-session` for a private bus. macOS does not use MPRIS.
|
||||||
- **macOS: Control Center shows only a title?** mpv publishes the stream's live
|
|
||||||
“Artist - Title” as the Now Playing title; artist/album fields and artwork are
|
|
||||||
not filled in by macOS for plain audio streams. The TUI itself always shows
|
|
||||||
the full metadata and album art.
|
|
||||||
- **Bandwidth** is unchanged in SYNC mode (the monitor capture creates no
|
|
||||||
second stream). The FFT fallback opens one extra stream; if you are on a
|
|
||||||
metered link, use `--spectrum-source monitor` (or `--no-audio` for a UI-only
|
|
||||||
demo).
|
|
||||||
- SomaFM is listener-supported: if you enjoy it, [donate](https://somafm.com/support/).
|
|
||||||
- tilderadio is a volunteer-run community station: [drop by](https://tilderadio.org),
|
|
||||||
or show up on the [schedule](https://tilderadio.org/schedule/) with a set.
|
|
||||||
- Bassdrive is free and listener-supported too: [bassdrive.com](https://bassdrive.com)
|
|
||||||
has the shows, archives and the shop that keeps the relays running.
|
|
||||||
|
|
||||||
## NixOS (optional aside)
|
## Development
|
||||||
|
|
||||||
None of the above needs Nix. If you *are* on NixOS, `shell.nix` wraps the same
|
|
||||||
`uv sync` / `uv run` workflow in an FHS environment, because uv installs
|
|
||||||
manylinux wheels such as NumPy that expect `libstdc++.so.6` in the usual
|
|
||||||
`/usr/lib` paths:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nix-shell # enter the FHS dev shell
|
uv sync # create the environment
|
||||||
uv sync
|
uv run pytest # test suite
|
||||||
uv run beefy-player
|
uvx ruff check . # lint
|
||||||
```
|
```
|
||||||
|
|
||||||
- If you see `libstdc++.so.6: cannot open shared object file`, make sure you
|
Each catalogue is a self-contained provider module under
|
||||||
are inside `nix-shell`, then recreate the venv once with
|
`beefy_player/providers/`; `base.py` documents the interface. `tools/ansi_art.py`
|
||||||
`rm -rf .venv && uv sync`.
|
converts a PNG logo into half-block ANSI art. On NixOS, `shell.nix` provides an
|
||||||
- `nix-shell --run "..."` does not work with FHS `shell.nix` files; enter the
|
FHS shell for 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
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user