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:
beefcarver committed 2026-11-03 16:10:25 -05:00
1 parent 4bed90b489
commit e30ca09627
44 files changed
+329 -192

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
Copyright (C) 2026 somafm-tui contributors
Copyright (C) 2026 beefy-player contributors
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
+140 -61
View File
@@ -1,19 +1,25 @@
# somafm-tui
# Beefy Player
![somafm-tui demo](assets/somafm-tui-demo.gif)
![beefy-player demo](assets/beefy-player-demo.gif)
A flashy terminal radio for **[SomaFM](https://somafm.com)** — a live spectrum
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.
+3
View File
@@ -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.
"""
+11 -9
View File
@@ -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()
+13 -14
View File
@@ -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 {
+1
View File
@@ -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
View File
@@ -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"]
+7 -7
View File
@@ -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)"
-3
View File
@@ -1,3 +0,0 @@
"""A flashy terminal radio player for somafm.com."""
__version__ = "0.1.0"
-1
View File
@@ -1 +0,0 @@
"""Custom Textual widgets for somafm-tui."""
+6 -6
View File
@@ -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 -4
View File
@@ -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:
+1 -1
View File
@@ -5,7 +5,7 @@ import json
import httpx
import pytest
from somafm_tui import bandcamp
from beefy_player import bandcamp
TRACK_PAYLOAD = {
"auto": {
+1 -1
View File
@@ -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}$")
+1 -1
View File
@@ -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 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+3 -3
View File
@@ -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,
+3 -3
View File
@@ -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
View File
@@ -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()
+1 -1
View File
@@ -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")
Generated
+1 -1
View File
@@ -683,7 +683,7 @@ wheels = [
]
[[package]]
name = "somafm-tui"
name = "beefy-player"
version = "0.1.0"
source = { editable = "." }
dependencies = [