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,4 +1,4 @@
|
||||
Copyright (C) 2026 somafm-tui contributors
|
||||
Copyright (C) 2026 beefy-player contributors
|
||||
|
||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||
Version 2, December 2004
|
||||
|
||||
@@ -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
|
||||
|
||||
File renamed without changes.
@@ -0,0 +1,3 @@
|
||||
"""Beefy Player — a flashy terminal player for internet radio."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
File renamed without changes.
File renamed without changes.
@@ -1,16 +1,16 @@
|
||||
"""Terminal artwork support.
|
||||
|
||||
The TUI draws a procedural ANSI mosaic when the terminal cannot display real
|
||||
images. On terminals with a graphics protocol we fetch the SomaFM album art
|
||||
(or channel logo) and render it directly, with the mosaic kept behind it:
|
||||
images. On terminals with a graphics protocol we fetch the station's album
|
||||
art (or channel logo) and render it directly, with the mosaic behind it:
|
||||
|
||||
* Kitty -> Terminal Graphics Protocol (direct image placement)
|
||||
* iTerm2 / WezTerm -> native iTerm2 inline images (OSC 1337)
|
||||
|
||||
The protocols are implemented directly; there is no terminal capability probe
|
||||
and no extra terminal input, so nothing leaks into the shell at exit.
|
||||
Set ``SOMAFM_IMAGES=0`` to force the ANSI fallback, or ``SOMAFM_IMAGES=tgp`` /
|
||||
``SOMAFM_IMAGES=iterm2`` to force a protocol.
|
||||
Set ``BEEFY_IMAGES=0`` to force the ANSI fallback, or ``BEEFY_IMAGES=tgp`` /
|
||||
``BEEFY_IMAGES=iterm2`` to force a protocol.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -47,7 +47,9 @@ def detect_protocol(environ: Mapping[str, str] | None = None) -> str | None:
|
||||
env = os.environ if environ is None else environ
|
||||
if "NO_COLOR" in env:
|
||||
return None
|
||||
override = (env.get("SOMAFM_IMAGES") or "").strip().lower()
|
||||
override = (
|
||||
env.get("BEEFY_IMAGES") or env.get("SOMAFM_IMAGES") or ""
|
||||
).strip().lower()
|
||||
if override in _OFF_VALUES:
|
||||
return None
|
||||
if override in _PROTOCOL_VALUES:
|
||||
@@ -154,7 +156,7 @@ def cell_size() -> tuple[int, int]:
|
||||
|
||||
def _cache_path(url: str):
|
||||
digest = hashlib.sha1(url.encode("utf-8")).hexdigest()
|
||||
return config.CACHE_DIR / "artwork" / digest
|
||||
return config.ARTWORK_CACHE_DIR / digest
|
||||
|
||||
|
||||
def load_cached(url: str) -> bytes | None:
|
||||
@@ -197,13 +199,13 @@ def diagnostics() -> dict[str, object]:
|
||||
"ITERM_SESSION_ID": env.get("ITERM_SESSION_ID", ""),
|
||||
"TMUX": env.get("TMUX", ""),
|
||||
"ZELLIJ": env.get("ZELLIJ", ""),
|
||||
"SOMAFM_IMAGES": env.get("SOMAFM_IMAGES", ""),
|
||||
"BEEFY_IMAGES": env.get("BEEFY_IMAGES") or env.get("SOMAFM_IMAGES", ""),
|
||||
"NO_COLOR": "set" if "NO_COLOR" in env else "",
|
||||
"protocol": PROTOCOL,
|
||||
"supported": supported(),
|
||||
"cell_size": cell_size(),
|
||||
"pillow": pkg_version("pillow"),
|
||||
"cache": str(config.CACHE_DIR / "artwork"),
|
||||
"cache": str(config.ARTWORK_CACHE_DIR),
|
||||
}
|
||||
|
||||
|
||||
File renamed without changes.
@@ -2,10 +2,14 @@
|
||||
|
||||
Everything lives in the usual XDG places:
|
||||
|
||||
~/.config/somafm-tui/config.json favourites, volume, last station
|
||||
~/.cache/somafm-tui/channels.json last good copy of somafm.com/channels.json
|
||||
~/.cache/somafm-tui/playlists.json resolved .pls -> stream URLs
|
||||
~/.cache/somafm-tui/songs/<id>.json last good copy of the song history
|
||||
~/.config/beefy-player/config.json favourites, volume, station
|
||||
~/.cache/beefy-player/channels.json last good station list
|
||||
~/.cache/beefy-player/playlists.json resolved .pls -> stream URLs
|
||||
~/.cache/beefy-player/songs/<id>.json last good song history
|
||||
~/.cache/beefy-player/artwork/<sha1> cached album/channel artwork
|
||||
|
||||
Settings from the pre-rename ``somafm-tui`` directory are copied over once, so
|
||||
upgrading does not lose favourites or volume.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,11 +17,26 @@ from __future__ import annotations
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
APP_NAME = "somafm-tui"
|
||||
from . import __version__
|
||||
|
||||
#: Directory name for config, cache and runtime files.
|
||||
APP_NAME = "beefy-player"
|
||||
|
||||
#: Name the app used before it became Beefy Player; only read, once, to migrate.
|
||||
LEGACY_APP_NAME = "somafm-tui"
|
||||
|
||||
#: What we send as ``User-Agent`` to catalogues, icecast mirrors and mpv.
|
||||
USER_AGENT = f"{APP_NAME}/{__version__} (terminal radio)"
|
||||
|
||||
#: Environment variables are namespaced ``BEEFY_*``; the old ``SOMAFM_*`` names
|
||||
#: are still honoured so existing shell profiles keep working.
|
||||
ENV_PREFIX = "BEEFY_"
|
||||
LEGACY_ENV_PREFIX = "SOMAFM_"
|
||||
|
||||
|
||||
def _xdg(env_var: str, fallback: str) -> Path:
|
||||
@@ -27,6 +46,14 @@ def _xdg(env_var: str, fallback: str) -> Path:
|
||||
return Path.home() / fallback
|
||||
|
||||
|
||||
def env_var(suffix: str) -> str:
|
||||
"""``BEEFY_<suffix>``, falling back to the legacy ``SOMAFM_<suffix>``."""
|
||||
value = os.environ.get(ENV_PREFIX + suffix)
|
||||
if value is None:
|
||||
value = os.environ.get(LEGACY_ENV_PREFIX + suffix)
|
||||
return (value or "").strip()
|
||||
|
||||
|
||||
CONFIG_DIR = _xdg("XDG_CONFIG_HOME", ".config") / APP_NAME
|
||||
CACHE_DIR = _xdg("XDG_CACHE_HOME", ".cache") / APP_NAME
|
||||
RUNTIME_DIR = Path(os.environ.get("XDG_RUNTIME_DIR") or "/tmp") / APP_NAME
|
||||
@@ -35,6 +62,10 @@ CONFIG_PATH = CONFIG_DIR / "config.json"
|
||||
CHANNELS_CACHE = CACHE_DIR / "channels.json"
|
||||
PLAYLIST_CACHE = CACHE_DIR / "playlists.json"
|
||||
SONGS_CACHE_DIR = CACHE_DIR / "songs"
|
||||
ARTWORK_CACHE_DIR = CACHE_DIR / "artwork"
|
||||
|
||||
#: Pre-rename location, read once by :meth:`Config.load`.
|
||||
LEGACY_CONFIG_PATH = _xdg("XDG_CONFIG_HOME", ".config") / LEGACY_APP_NAME / "config.json"
|
||||
|
||||
VISUALIZERS = ("both", "bars", "wave", "waterfall", "radial", "braille")
|
||||
|
||||
@@ -65,6 +96,21 @@ def write_json(path: Path, payload: Any) -> None:
|
||||
atomic_write_text(path, json.dumps(payload, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
def migrate_legacy_state() -> None:
|
||||
"""Copy a pre-rename config file into place, once.
|
||||
|
||||
Favourites, volume and the last station are the only state worth keeping;
|
||||
caches are rebuilt on the next run.
|
||||
"""
|
||||
try:
|
||||
if CONFIG_PATH.exists() or not LEGACY_CONFIG_PATH.is_file():
|
||||
return
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(LEGACY_CONFIG_PATH, CONFIG_PATH)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Config:
|
||||
favorites: list[str] = field(default_factory=list)
|
||||
@@ -72,6 +118,8 @@ class Config:
|
||||
volume: int = 70
|
||||
visualizer: str = "both"
|
||||
autoplay: bool = False
|
||||
#: Which catalogue to use by default (see :mod:`beefy_player.providers`).
|
||||
provider: str = "somafm"
|
||||
#: Publish what is playing to the desktop media session (MPRIS on Linux,
|
||||
#: macOS Now Playing via mpv) and honour the media keys. The JSON key is
|
||||
#: spelled ``mpris`` for backwards compatibility.
|
||||
@@ -79,6 +127,7 @@ class Config:
|
||||
|
||||
@classmethod
|
||||
def load(cls) -> Config:
|
||||
migrate_legacy_state()
|
||||
raw = read_json(CONFIG_PATH)
|
||||
config = cls()
|
||||
if not isinstance(raw, dict):
|
||||
@@ -100,6 +149,10 @@ class Config:
|
||||
if visualizer in VISUALIZERS:
|
||||
config.visualizer = visualizer
|
||||
|
||||
provider = raw.get("provider")
|
||||
if isinstance(provider, str) and provider.strip():
|
||||
config.provider = provider.strip().lower()
|
||||
|
||||
if isinstance(raw.get("autoplay"), bool):
|
||||
config.autoplay = raw["autoplay"]
|
||||
if isinstance(raw.get("mpris"), bool):
|
||||
@@ -407,7 +407,7 @@ class SystemAudioTap:
|
||||
if not nsuuid:
|
||||
raise TapError("could not create the tap UUID")
|
||||
_send_object(description, b"setUUID:", nsuuid)
|
||||
_send_object(description, b"setName:", cfstr("somafm-tui"))
|
||||
_send_object(description, b"setName:", cfstr("beefy-player"))
|
||||
_send_flag(description, b"setPrivate:", True)
|
||||
# 0 == CATapUnmuted: the audio we record keeps playing as usual.
|
||||
_send_integer(description, b"setMuteBehavior:", 0)
|
||||
@@ -460,7 +460,7 @@ class SystemAudioTap:
|
||||
owned.append(aggregate)
|
||||
for key, value in (
|
||||
(_AGGREGATE_UID, cfstr(str(uuid.uuid4()).upper())),
|
||||
(_AGGREGATE_NAME, cfstr("somafm-tui tap")),
|
||||
(_AGGREGATE_NAME, cfstr("beefy-player tap")),
|
||||
(_AGGREGATE_PRIVATE, _boolean(True)),
|
||||
(_AGGREGATE_STACKED, _boolean(False)),
|
||||
(_AGGREGATE_TAPS, tap_list),
|
||||
@@ -10,15 +10,15 @@ The rest of the app only ever talks to this small surface::
|
||||
|
||||
which keeps the platform differences in one place:
|
||||
|
||||
* **Linux / BSD / any D-Bus desktop** — :class:`somafm_tui.mpris.MprisService`
|
||||
exports ``org.mpris.MediaPlayer2.somafmtui.instance<pid>`` on the session
|
||||
* **Linux / BSD / any D-Bus desktop** — :class:`beefy_player.mpris.MprisService`
|
||||
exports ``org.mpris.MediaPlayer2.beefyplayer.instance<pid>`` on the session
|
||||
bus, so GNOME/KDE widgets, lock screens and ``playerctl`` show the station
|
||||
and can drive playback.
|
||||
* **macOS** — mpv *is* the app the system knows about: its Cocoa backend
|
||||
registers with ``MPRemoteCommandCenter``/``MPNowPlayingInfoCenter``, which
|
||||
is what Control Center, the menu bar and the media keys talk to. So there
|
||||
is nothing to publish here, and the media keys are routed back into the TUI
|
||||
by mpv (see :mod:`somafm_tui.player`). :class:`NowPlayingSession` only
|
||||
by mpv (see :mod:`beefy_player.player`). :class:`NowPlayingSession` only
|
||||
keeps the state the status bar renders and stays out of the way.
|
||||
|
||||
Anything else (or a disabled/absent backend) degrades to an inert session
|
||||
@@ -114,7 +114,7 @@ class NowPlayingSession(MediaSession):
|
||||
"""macOS: mpv owns the system Now Playing session, we just mirror state.
|
||||
|
||||
mpv's Cocoa backend publishes the live ICY title (and play/pause state) to
|
||||
Control Center and receives the media keys; :mod:`somafm_tui.player` routes
|
||||
Control Center and receives the media keys; :mod:`beefy_player.player` routes
|
||||
those key presses back into this app. ``available`` is false when the audio
|
||||
backend is not mpv, because then nobody publishes anything.
|
||||
"""
|
||||
@@ -104,10 +104,12 @@ def build_metadata(
|
||||
(track_key or f"{station}|{artist}|{title}").encode("utf-8")
|
||||
).hexdigest()[:24]
|
||||
metadata: dict[str, Any] = {
|
||||
"mpris:trackid": Variant("o", f"/org/somafm/track/{digest}"),
|
||||
"xesam:title": Variant("s", title or station or "SomaFM"),
|
||||
"xesam:artist": Variant("as", [artist] if artist else ["SomaFM"]),
|
||||
"xesam:comment": Variant("as", [f"SomaFM · {station}"] if station else ["SomaFM"]),
|
||||
"mpris:trackid": Variant("o", f"/org/beefyplayer/track/{digest}"),
|
||||
"xesam:title": Variant("s", title or station or "Beefy Player"),
|
||||
"xesam:artist": Variant("as", [artist] if artist else ["Beefy Player"]),
|
||||
"xesam:comment": Variant(
|
||||
"as", [f"Beefy Player · {station}"] if station else ["Beefy Player"]
|
||||
),
|
||||
}
|
||||
if album:
|
||||
metadata["xesam:album"] = Variant("s", album)
|
||||
@@ -125,11 +127,11 @@ class _RootInterface(ServiceInterface):
|
||||
|
||||
@dbus_property(name="Identity", access=PropertyAccess.READ)
|
||||
def Identity(self) -> "s":
|
||||
return "SomaFM TUI"
|
||||
return "Beefy Player"
|
||||
|
||||
@dbus_property(name="DesktopEntry", access=PropertyAccess.READ)
|
||||
def DesktopEntry(self) -> "s":
|
||||
return "somafm-tui"
|
||||
return "beefy-player"
|
||||
|
||||
@dbus_property(name="CanQuit", access=PropertyAccess.READ)
|
||||
def CanQuit(self) -> "b":
|
||||
@@ -284,7 +286,7 @@ class _PlayerInterface(ServiceInterface):
|
||||
|
||||
|
||||
class MprisService:
|
||||
#: Status-bar name for this backend (see :mod:`somafm_tui.mediasession`).
|
||||
#: Status-bar name for this backend (see :mod:`beefy_player.mediasession`).
|
||||
label = "mpris"
|
||||
|
||||
def __init__(
|
||||
@@ -306,7 +308,7 @@ class MprisService:
|
||||
self._status = "Stopped"
|
||||
self._metadata: dict[str, Any] = {}
|
||||
self.bus_name = (
|
||||
f"org.mpris.MediaPlayer2.somafmtui.instance{os.getpid()}"
|
||||
f"org.mpris.MediaPlayer2.beefyplayer.instance{os.getpid()}"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -317,7 +319,7 @@ class MprisService:
|
||||
if not self.enabled:
|
||||
return
|
||||
self._thread = threading.Thread(
|
||||
target=self._thread_main, name="somafm-mpris", daemon=True
|
||||
target=self._thread_main, name="beefy-player-mpris", daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
@@ -28,8 +28,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .api import USER_AGENT
|
||||
from .config import RUNTIME_DIR, atomic_write_text
|
||||
from .config import RUNTIME_DIR, USER_AGENT, atomic_write_text, env_var
|
||||
|
||||
#: mpv input bindings that turn the system media keys into TUI commands.
|
||||
#:
|
||||
@@ -39,20 +38,20 @@ from .config import RUNTIME_DIR, atomic_write_text
|
||||
#: list, the spectrum and the desktop "now playing" state) we bounce them back
|
||||
#: over the IPC socket as ``client-message`` events.
|
||||
MEDIA_KEY_BINDINGS = """\
|
||||
# Generated by somafm-tui -- do not edit; it is rewritten on every launch.
|
||||
PLAY script-message somafm toggle
|
||||
PLAYONLY script-message somafm play
|
||||
PAUSEONLY script-message somafm pause
|
||||
STOP script-message somafm stop
|
||||
NEXT script-message somafm next
|
||||
PREV script-message somafm prev
|
||||
# Generated by beefy-player -- do not edit; it is rewritten on every launch.
|
||||
PLAY script-message beefy toggle
|
||||
PLAYONLY script-message beefy play
|
||||
PAUSEONLY script-message beefy pause
|
||||
STOP script-message beefy stop
|
||||
NEXT script-message beefy next
|
||||
PREV script-message beefy prev
|
||||
"""
|
||||
|
||||
#: Commands a media key may ask for; everything else is ignored.
|
||||
MEDIA_COMMANDS = frozenset({"play", "pause", "toggle", "stop", "next", "prev"})
|
||||
|
||||
#: Prefix of the ``client-message`` namespace we listen on.
|
||||
MEDIA_MESSAGE_PREFIX = "somafm"
|
||||
MEDIA_MESSAGE_PREFIX = "beefy"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -113,7 +112,7 @@ class Player:
|
||||
)
|
||||
return
|
||||
self._thread = threading.Thread(
|
||||
target=self._thread_main, name="somafm-player", daemon=True
|
||||
target=self._thread_main, name="beefy-player", daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
self._ready.wait(10)
|
||||
@@ -316,12 +315,12 @@ class Player:
|
||||
"--cache=yes",
|
||||
"--cache-secs=20",
|
||||
"--demuxer-max-bytes=32MiB",
|
||||
"--audio-client-name=somafm-tui",
|
||||
"--audio-client-name=beefy-player",
|
||||
f"--user-agent={USER_AGENT}",
|
||||
"--network-timeout=20",
|
||||
]
|
||||
command.extend(self._platform_options())
|
||||
extra = os.environ.get("SOMAFM_MPV_ARGS")
|
||||
extra = env_var("MPV_ARGS")
|
||||
if extra:
|
||||
command.extend(shlex.split(extra))
|
||||
|
||||
@@ -466,7 +465,7 @@ class Player:
|
||||
self.events.put(PlayerEvent(kind="notice", detail=str(error)))
|
||||
|
||||
def _handle_client_message(self, args: Any) -> None:
|
||||
"""Turn ``script-message somafm <command>`` into a player event.
|
||||
"""Turn ``script-message beefy <command>`` into a player event.
|
||||
|
||||
mpv's macOS backend feeds the system media keys (and Control Center
|
||||
buttons) into its input system; ``MEDIA_KEY_BINDINGS`` maps them onto
|
||||
File renamed without changes.
File renamed without changes.
@@ -7,9 +7,9 @@ leaves the speakers (status ``SYNC``).
|
||||
That capture is platform specific:
|
||||
|
||||
* **Linux** — ``parec`` records the PulseAudio/PipeWire monitor of the default
|
||||
sink (or ``SOMAFM_SPECTRUM_DEVICE``).
|
||||
sink (or ``BEEFY_SPECTRUM_DEVICE``).
|
||||
* **macOS** — a native Core Audio *process tap* records the default output
|
||||
device (see :mod:`somafm_tui.macos_tap`). Nothing to install: no virtual
|
||||
device (see :mod:`beefy_player.macos_tap`). Nothing to install: no virtual
|
||||
loopback, no Multi-Output Device, no helper binary. macOS asks for
|
||||
**System Audio Recording** permission the first time; while it is missing
|
||||
the tap delivers silence, which is treated as a failed capture.
|
||||
@@ -35,7 +35,7 @@ from dataclasses import dataclass
|
||||
import numpy as np
|
||||
|
||||
from . import macos_tap
|
||||
from .api import USER_AGENT
|
||||
from .config import USER_AGENT, env_var
|
||||
|
||||
SAMPLE_RATE = 44100
|
||||
FFT_SIZE = 2048
|
||||
@@ -164,7 +164,7 @@ class SpectrumEngine:
|
||||
if self._thread is not None:
|
||||
return
|
||||
self._thread = threading.Thread(
|
||||
target=self._run, name="somafm-spectrum", daemon=True
|
||||
target=self._run, name="beefy-player-spectrum", daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
@@ -315,7 +315,7 @@ class SpectrumEngine:
|
||||
return None
|
||||
|
||||
def _monitor_device(self) -> str:
|
||||
override = os.environ.get("SOMAFM_SPECTRUM_DEVICE", "").strip()
|
||||
override = env_var("SPECTRUM_DEVICE")
|
||||
if override:
|
||||
return override
|
||||
if self._monitor_device_cache is not None:
|
||||
@@ -351,7 +351,7 @@ class SpectrumEngine:
|
||||
"--channels=1",
|
||||
"--raw",
|
||||
"--latency-msec=30",
|
||||
"--client-name=somafm-tui",
|
||||
"--client-name=beefy-player",
|
||||
]
|
||||
|
||||
def _spawn(
|
||||
@@ -1,4 +1,4 @@
|
||||
/* somafm-tui — stock terminal palette, transparent backgrounds.
|
||||
/* beefy-player — stock terminal palette, transparent backgrounds.
|
||||
Colours are ANSI names only; this file never paints a background. */
|
||||
|
||||
Screen {
|
||||
@@ -0,0 +1 @@
|
||||
"""Custom Textual widgets for Beefy Player."""
|
||||
@@ -28,7 +28,7 @@ _NULL_STYLE = Style()
|
||||
_NULL_CONTROL = ((ControlType.CURSOR_FORWARD, 0),)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import SomaFMApp
|
||||
from ..app import BeefyPlayerApp
|
||||
|
||||
|
||||
_IMAGE_IDS = itertools.count(random.randrange(1, 2**31 - 1))
|
||||
@@ -314,7 +314,7 @@ class ArtPane(Widget):
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
app = cast("SomaFMApp", self.app)
|
||||
app = cast("BeefyPlayerApp", self.app)
|
||||
station = app.current
|
||||
if station is None:
|
||||
return Text("")
|
||||
@@ -341,7 +341,7 @@ class ArtPane(Widget):
|
||||
# Keep the mosaic behind the image in sync with the current track.
|
||||
self.refresh()
|
||||
|
||||
app = cast("SomaFMApp", self.app)
|
||||
app = cast("BeefyPlayerApp", self.app)
|
||||
station = app.current
|
||||
data = app.artwork_bytes
|
||||
width = max(1, self.size.width)
|
||||
File renamed without changes.
@@ -19,7 +19,7 @@ from .common import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import SomaFMApp
|
||||
from ..app import BeefyPlayerApp
|
||||
|
||||
LOGO = (
|
||||
"▄▀▀▀ ▄▀▀▄ █▄ ▄█ ▄▀▀▄",
|
||||
@@ -34,7 +34,7 @@ class HeaderBar(Widget):
|
||||
can_focus = False
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
app = cast("SomaFMApp", self.app)
|
||||
app = cast("BeefyPlayerApp", self.app)
|
||||
width = max(24, self.size.width)
|
||||
height = max(1, self.size.height)
|
||||
state = app.playing_state
|
||||
File renamed without changes.
File renamed without changes.
@@ -15,7 +15,7 @@ from ..spectrum import Frame, SpectrumEngine
|
||||
from .common import LEVELS, SPECTRUM_STOPS, gradient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import SomaFMApp
|
||||
from ..app import BeefyPlayerApp
|
||||
|
||||
#: One waterfall column per analyser tick; a deque this long covers ~25 s of
|
||||
#: history at 20 Hz, plenty for even a very wide terminal.
|
||||
@@ -62,7 +62,7 @@ class SpectrumPanel(Widget):
|
||||
self.refresh()
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
app = cast("SomaFMApp", self.app)
|
||||
app = cast("BeefyPlayerApp", self.app)
|
||||
frame = self.engine.snapshot()
|
||||
width = max(8, self.size.width)
|
||||
height = max(2, self.size.height)
|
||||
File renamed without changes.
@@ -12,7 +12,7 @@ from textual.widget import Widget
|
||||
from .common import compact_count, truncate
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import SomaFMApp
|
||||
from ..app import BeefyPlayerApp
|
||||
|
||||
|
||||
class StationList(Widget):
|
||||
@@ -29,13 +29,13 @@ class StationList(Widget):
|
||||
self.border_title = title
|
||||
|
||||
def on_mouse_scroll_down(self, event) -> None:
|
||||
cast("SomaFMApp", self.app).action_cursor_down()
|
||||
cast("BeefyPlayerApp", self.app).action_cursor_down()
|
||||
|
||||
def on_mouse_scroll_up(self, event) -> None:
|
||||
cast("SomaFMApp", self.app).action_cursor_up()
|
||||
cast("BeefyPlayerApp", self.app).action_cursor_up()
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
app = cast("SomaFMApp", self.app)
|
||||
app = cast("BeefyPlayerApp", self.app)
|
||||
width = max(12, self.size.width)
|
||||
height = max(1, self.size.height)
|
||||
view = app.view
|
||||
@@ -12,7 +12,7 @@ from textual.widget import Widget
|
||||
from .common import compact_count, state_glyph, truncate
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import SomaFMApp
|
||||
from ..app import BeefyPlayerApp
|
||||
|
||||
HINTS_LONG = (
|
||||
"SPACE pause ↑↓ station ENTER tune +/- vol f ★ / filter "
|
||||
@@ -28,7 +28,7 @@ class StatusBar(Widget):
|
||||
can_focus = False
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
app = cast("SomaFMApp", self.app)
|
||||
app = cast("BeefyPlayerApp", self.app)
|
||||
width = max(20, self.size.width)
|
||||
glyph, colour = state_glyph(app.playing_state)
|
||||
frame = app.spectrum.snapshot()
|
||||
+9
-9
@@ -1,13 +1,13 @@
|
||||
[project]
|
||||
name = "somafm-tui"
|
||||
name = "beefy-player"
|
||||
version = "0.1.0"
|
||||
description = "A flashy terminal radio for SomaFM with a live spectrum analyser, MPRIS / macOS Now Playing support and song history"
|
||||
description = "A flashy terminal player for internet radio — live spectrum analyser, MPRIS / macOS Now Playing support, song history and pluggable station catalogues (SomaFM included)"
|
||||
readme = "README.md"
|
||||
license = "WTFPL"
|
||||
license-files = ["LICENSE"]
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "somafm-tui contributors" }]
|
||||
keywords = ["somafm", "radio", "tui", "terminal", "mpris", "audio"]
|
||||
authors = [{ name = "beefy-player contributors" }]
|
||||
keywords = ["radio", "internet-radio", "tui", "terminal", "mpris", "audio", "somafm"]
|
||||
classifiers = [
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
@@ -31,17 +31,17 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
somafm-tui = "somafm_tui.__main__:main"
|
||||
beefy-player = "beefy_player.__main__:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["somafm_tui"]
|
||||
packages = ["beefy_player"]
|
||||
|
||||
[tool.hatch.build.targets.wheel.force-include]
|
||||
"somafm_tui/theme.tcss" = "somafm_tui/theme.tcss"
|
||||
"beefy_player/theme.tcss" = "beefy_player/theme.tcss"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
@@ -60,8 +60,8 @@ extend-exclude = ["dist", ".venv", "build"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E4", "E7", "E9", "F", "I", "UP", "B", "SIM", "RUF"]
|
||||
ignore = ["RUF001"] # en/em dashes are part of real SomaFM metadata
|
||||
ignore = ["RUF001"] # en/em dashes are part of real station metadata
|
||||
# dbus-next needs short signature strings ("s", "a{sv}", "x", …) in
|
||||
# annotations; they are not Python names.
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"somafm_tui/mpris.py" = ["F821", "F722", "UP037"]
|
||||
"beefy_player/mpris.py" = ["F821", "F722", "UP037"]
|
||||
@@ -1,6 +1,6 @@
|
||||
{ pkgs ? import <nixpkgs> { } }:
|
||||
|
||||
# FHS development shell for somafm-tui.
|
||||
# FHS development shell for beefy-player.
|
||||
#
|
||||
# Why FHS? uv installs manylinux wheels from PyPI (notably NumPy) that expect
|
||||
# libstdc++.so.6 and friends in the usual FHS locations (/usr/lib, /lib).
|
||||
@@ -12,13 +12,13 @@
|
||||
# nix-shell # enter the FHS shell
|
||||
# rm -rf .venv # once, if the venv was created by an older setup
|
||||
# uv sync # install Python deps with uv
|
||||
# uv run somafm-tui # start the radio
|
||||
# uv run beefy-player # start the radio
|
||||
#
|
||||
# Note: `nix-shell --run "..."` is not supported by FHS shell.nix files; enter
|
||||
# the shell interactively and run commands there.
|
||||
|
||||
(pkgs.buildFHSEnv {
|
||||
name = "somafm-tui";
|
||||
name = "beefy-player";
|
||||
|
||||
targetPkgs = pkgs: with pkgs; [
|
||||
# --- Python toolchain; uv owns all Python dependencies ---------------
|
||||
@@ -83,13 +83,13 @@
|
||||
export DBUS_SESSION_BUS_ADDRESS="unix:path=$XDG_RUNTIME_DIR/bus"
|
||||
fi
|
||||
|
||||
export PS1='\[\e[1;35m\][somafm-tui]\[\e[0m\] \w \$ '
|
||||
export PS1='\[\e[1;35m\][beefy-player]\[\e[0m\] \w \$ '
|
||||
|
||||
echo ""
|
||||
echo " somafm-tui — FHS dev shell"
|
||||
echo " --------------------------"
|
||||
echo " beefy-player — FHS dev shell"
|
||||
echo " ----------------------------"
|
||||
echo " uv sync install / update python deps"
|
||||
echo " uv run somafm-tui start the radio"
|
||||
echo " uv run beefy-player start the radio"
|
||||
echo ""
|
||||
echo " python : $(python3 --version 2>&1)"
|
||||
echo " uv : $(uv --version 2>&1)"
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
"""A flashy terminal radio player for somafm.com."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -1 +0,0 @@
|
||||
"""Custom Textual widgets for somafm-tui."""
|
||||
+6
-6
@@ -3,11 +3,11 @@ from __future__ import annotations
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from somafm_tui import artwork
|
||||
from somafm_tui.app import SomaFMApp
|
||||
from somafm_tui.widgets import art as art_widgets
|
||||
from somafm_tui.widgets.art import ArtPane, ITerm2Image, KittyImage
|
||||
from somafm_tui.widgets.stage import StagePanel
|
||||
from beefy_player import artwork
|
||||
from beefy_player.app import BeefyPlayerApp
|
||||
from beefy_player.widgets import art as art_widgets
|
||||
from beefy_player.widgets.art import ArtPane, ITerm2Image, KittyImage
|
||||
from beefy_player.widgets.stage import StagePanel
|
||||
|
||||
|
||||
async def _no_fetch(client, url: str) -> None:
|
||||
@@ -27,7 +27,7 @@ async def test_art_pane_switches_between_image_and_mosaic(monkeypatch) -> None:
|
||||
monkeypatch.setattr(artwork, "load_cached", lambda url: None)
|
||||
monkeypatch.setattr(artwork, "fetch", _no_fetch)
|
||||
|
||||
app = SomaFMApp(autoplay=False, offline=True, audio=False)
|
||||
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||
async with app.run_test(size=(110, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
pane = app.query_one(ArtPane)
|
||||
|
||||
@@ -6,7 +6,7 @@ import httpx
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from somafm_tui import artwork
|
||||
from beefy_player import artwork
|
||||
|
||||
|
||||
def png_bytes(size=(400, 200)) -> bytes:
|
||||
@@ -28,9 +28,11 @@ def test_protocol_detection() -> None:
|
||||
assert artwork.detect_protocol({"ZELLIJ": "1", "TERM_PROGRAM": "WezTerm"}) is None
|
||||
assert artwork.detect_protocol({"KITTY_PID": "1"}) == "tgp"
|
||||
assert artwork.detect_protocol({"NO_COLOR": "", "KITTY_WINDOW_ID": "1"}) is None
|
||||
assert artwork.detect_protocol({"SOMAFM_IMAGES": "0", "KITTY_WINDOW_ID": "1"}) is None
|
||||
assert artwork.detect_protocol({"SOMAFM_IMAGES": "tgp"}) == "tgp"
|
||||
assert artwork.detect_protocol({"SOMAFM_IMAGES": "iterm2"}) == "iterm2"
|
||||
assert artwork.detect_protocol({"BEEFY_IMAGES": "0", "KITTY_WINDOW_ID": "1"}) is None
|
||||
assert artwork.detect_protocol({"BEEFY_IMAGES": "tgp"}) == "tgp"
|
||||
assert artwork.detect_protocol({"BEEFY_IMAGES": "iterm2"}) == "iterm2"
|
||||
# the pre-rename variable still works
|
||||
assert artwork.detect_protocol({"SOMAFM_IMAGES": "0"}) is None
|
||||
|
||||
|
||||
def test_fit_image_crops_to_the_cell_box() -> None:
|
||||
|
||||
@@ -5,7 +5,7 @@ import json
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from somafm_tui import bandcamp
|
||||
from beefy_player import bandcamp
|
||||
|
||||
TRACK_PAYLOAD = {
|
||||
"auto": {
|
||||
|
||||
@@ -6,7 +6,7 @@ import types
|
||||
import pytest
|
||||
from rich.cells import cell_len
|
||||
|
||||
from somafm_tui.widgets.header import HeaderBar
|
||||
from beefy_player.widgets.header import HeaderBar
|
||||
|
||||
TIME = re.compile(r"\d\d:\d\d:\d\d$")
|
||||
DATE = re.compile(r"[A-Z][a-z]{2} \d\d [A-Z][a-z]{2}$")
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from somafm_tui import macos_tap
|
||||
from beefy_player import macos_tap
|
||||
|
||||
|
||||
def test_osstatus_decodes_four_character_codes() -> None:
|
||||
|
||||
@@ -3,8 +3,8 @@ from __future__ import annotations
|
||||
import queue
|
||||
import sys
|
||||
|
||||
from somafm_tui import mediasession
|
||||
from somafm_tui.mediasession import (
|
||||
from beefy_player import mediasession
|
||||
from beefy_player.mediasession import (
|
||||
MediaSession,
|
||||
NowPlayingSession,
|
||||
create_media_session,
|
||||
@@ -12,7 +12,7 @@ from somafm_tui.mediasession import (
|
||||
|
||||
|
||||
def test_linux_uses_mpris() -> None:
|
||||
from somafm_tui.mpris import MprisService
|
||||
from beefy_player.mpris import MprisService
|
||||
|
||||
session = create_media_session(queue.Queue(), enabled=False, platform="linux")
|
||||
assert isinstance(session, MprisService)
|
||||
|
||||
+4
-4
@@ -4,7 +4,7 @@ import queue
|
||||
|
||||
import pytest
|
||||
|
||||
from somafm_tui import mpris
|
||||
from beefy_player import mpris
|
||||
|
||||
|
||||
def test_build_metadata() -> None:
|
||||
@@ -17,16 +17,16 @@ def test_build_metadata() -> None:
|
||||
stream_url="https://ice2.somafm.com/groovesalad-256-mp3",
|
||||
track_key="groovesalad|Jens Buchert|Rauschebart|Gravity",
|
||||
)
|
||||
assert metadata["mpris:trackid"].value.startswith("/org/somafm/track/")
|
||||
assert metadata["mpris:trackid"].value.startswith("/org/beefyplayer/track/")
|
||||
assert metadata["xesam:title"].value == "Rauschebart"
|
||||
assert metadata["xesam:artist"].value == ["Jens Buchert"]
|
||||
assert metadata["xesam:album"].value == "Gravity"
|
||||
assert metadata["mpris:artUrl"].value == "https://example.com/art.jpg"
|
||||
assert metadata["xesam:url"].value.startswith("https://ice2.")
|
||||
assert metadata["xesam:comment"].value == ["SomaFM · Groove Salad"]
|
||||
assert metadata["xesam:comment"].value == ["Beefy Player · Groove Salad"]
|
||||
|
||||
minimal = mpris.build_metadata(station="X", title="", artist="")
|
||||
assert minimal["xesam:artist"].value == ["SomaFM"]
|
||||
assert minimal["xesam:artist"].value == ["Beefy Player"]
|
||||
assert "xesam:album" not in minimal
|
||||
assert "mpris:artUrl" not in minimal
|
||||
|
||||
|
||||
+11
-11
@@ -5,7 +5,7 @@ import queue
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from somafm_tui.player import Player
|
||||
from beefy_player.player import Player
|
||||
|
||||
FAKE_MPV = '''#!/usr/bin/env python3
|
||||
import json, os, socket, sys, time
|
||||
@@ -171,27 +171,27 @@ def test_backend_detection_without_tools(tmp_path, monkeypatch) -> None:
|
||||
def test_media_key_messages_become_player_events() -> None:
|
||||
player = Player()
|
||||
player._handle_mpv_message(
|
||||
{"event": "client-message", "args": ["somafm", "next"]}
|
||||
{"event": "client-message", "args": ["beefy", "next"]}
|
||||
)
|
||||
event = player.events.get_nowait()
|
||||
assert event.kind == "media"
|
||||
assert event.detail == "next"
|
||||
|
||||
player._handle_mpv_message(
|
||||
{"event": "client-message", "args": ["somafm", "toggle"]}
|
||||
{"event": "client-message", "args": ["beefy", "toggle"]}
|
||||
)
|
||||
assert player.events.get_nowait().detail == "toggle"
|
||||
|
||||
# Foreign namespaces, unknown commands and malformed payloads are ignored.
|
||||
player._handle_mpv_message({"event": "client-message", "args": ["other", "next"]})
|
||||
player._handle_mpv_message({"event": "client-message", "args": ["somafm", "seek"]})
|
||||
player._handle_mpv_message({"event": "client-message", "args": ["beefy", "seek"]})
|
||||
player._handle_mpv_message({"event": "client-message", "args": None})
|
||||
player._handle_mpv_message({"event": "client-message"})
|
||||
assert player.events.empty()
|
||||
|
||||
|
||||
def test_platform_options_never_leak_to_linux(tmp_path, monkeypatch) -> None:
|
||||
import somafm_tui.player as player_module
|
||||
import beefy_player.player as player_module
|
||||
|
||||
monkeypatch.setattr(player_module, "RUNTIME_DIR", tmp_path)
|
||||
assert Player()._platform_options("linux") == []
|
||||
@@ -203,12 +203,12 @@ def test_platform_options_never_leak_to_linux(tmp_path, monkeypatch) -> None:
|
||||
assert conf.endswith("mpv-input.conf")
|
||||
bindings = (tmp_path / "mpv-input.conf").read_text()
|
||||
for line in (
|
||||
"PLAY script-message somafm toggle",
|
||||
"PLAYONLY script-message somafm play",
|
||||
"PAUSEONLY script-message somafm pause",
|
||||
"STOP script-message somafm stop",
|
||||
"NEXT script-message somafm next",
|
||||
"PREV script-message somafm prev",
|
||||
"PLAY script-message beefy toggle",
|
||||
"PLAYONLY script-message beefy play",
|
||||
"PAUSEONLY script-message beefy pause",
|
||||
"STOP script-message beefy stop",
|
||||
"NEXT script-message beefy next",
|
||||
"PREV script-message beefy prev",
|
||||
):
|
||||
assert line in bindings
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from somafm_tui import spectrum as spectrum_module
|
||||
from somafm_tui.spectrum import Frame, SpectrumEngine
|
||||
from beefy_player import spectrum as spectrum_module
|
||||
from beefy_player.spectrum import Frame, SpectrumEngine
|
||||
|
||||
PCM_SCRIPT = """#!/usr/bin/env python3
|
||||
import math, struct, sys, time
|
||||
@@ -42,7 +42,7 @@ def tap_pcm(
|
||||
|
||||
|
||||
class FakeTap:
|
||||
"""Stand-in for ``somafm_tui.macos_tap.SystemAudioTap``."""
|
||||
"""Stand-in for ``beefy_player.macos_tap.SystemAudioTap``."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -4,9 +4,9 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from somafm_tui import config
|
||||
from somafm_tui.spectrum import Frame, SpectrumEngine
|
||||
from somafm_tui.widgets.spectrum import (
|
||||
from beefy_player import config
|
||||
from beefy_player.spectrum import Frame, SpectrumEngine
|
||||
from beefy_player.widgets.spectrum import (
|
||||
VISUALIZER_LABELS,
|
||||
WATERFALL_HISTORY,
|
||||
SpectrumPanel,
|
||||
|
||||
+9
-9
@@ -5,15 +5,15 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from somafm_tui.app import SomaFMApp
|
||||
from somafm_tui.widgets.header import HeaderBar
|
||||
from somafm_tui.widgets.search import SearchInput
|
||||
from somafm_tui.widgets.spectrum import SpectrumPanel
|
||||
from somafm_tui.widgets.stage import StagePanel
|
||||
from somafm_tui.widgets.station_list import StationList
|
||||
from somafm_tui.widgets.status import StatusBar
|
||||
from beefy_player.app import BeefyPlayerApp
|
||||
from beefy_player.widgets.header import HeaderBar
|
||||
from beefy_player.widgets.search import SearchInput
|
||||
from beefy_player.widgets.spectrum import SpectrumPanel
|
||||
from beefy_player.widgets.stage import StagePanel
|
||||
from beefy_player.widgets.station_list import StationList
|
||||
from beefy_player.widgets.status import StatusBar
|
||||
|
||||
PACKAGE = Path(__file__).resolve().parents[1] / "somafm_tui"
|
||||
PACKAGE = Path(__file__).resolve().parents[1] / "beefy_player"
|
||||
|
||||
|
||||
def test_no_rgb_colors_in_source() -> None:
|
||||
@@ -29,7 +29,7 @@ def test_no_rgb_colors_in_source() -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backgrounds_use_terminal_default_and_borders_are_ansi() -> None:
|
||||
app = SomaFMApp(autoplay=False, offline=True, audio=False)
|
||||
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||
async with app.run_test(size=(100, 30)) as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from somafm_tui.widgets import common
|
||||
from beefy_player.widgets import common
|
||||
|
||||
BERLIN = Path("/usr/share/zoneinfo/Europe/Berlin")
|
||||
UTC_FILE = Path("/usr/share/zoneinfo/UTC")
|
||||
|
||||
Reference in new issue
Block a user