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
|
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||||
Version 2, December 2004
|
Version 2, December 2004
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
# somafm-tui
|
# Beefy Player
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
A flashy terminal radio for **[SomaFM](https://somafm.com)** — a live spectrum
|
A flashy terminal player for **internet radio** — a live spectrum analyser,
|
||||||
analyser, song history, and full desktop media integration, drawn with your
|
song history, and full desktop media integration, drawn with your terminal's
|
||||||
terminal's own ANSI palette on the terminal's default background so it fits
|
own ANSI palette on the terminal's default background so it fits any theme
|
||||||
any theme (including transparent terminals).
|
(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**
|
It runs the same way on **Linux** (PulseAudio/PipeWire) and **macOS**
|
||||||
(CoreAudio, iTerm2/Kitty/WezTerm): MPRIS on the desktop bus where that exists,
|
(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.
|
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
|
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
|
## 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
|
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
|
and fall back to the procedural ANSI mosaic for other terminals, failed
|
||||||
loads, or blocked graphics passthrough. Images are cached in
|
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
|
- **Now playing** — artist, title, album, DJ, listeners, format/bitrate and
|
||||||
"played N minutes ago", with procedural ANSI-shaded album art that changes
|
"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
|
per track. When the current track can be found on Bandcamp, a clickable
|
||||||
**Bandcamp ↗** link appears below it; otherwise the line stays hidden.
|
**Bandcamp ↗** link appears below it; otherwise the line stays hidden.
|
||||||
- **Last played** — the full recent history from `somafm.com/songs/<id>.json`.
|
- **Last played** — the full recent history, newest first.
|
||||||
- **Every station** — the whole `channels.json` roster (≈50 channels), live
|
- **Every station** — with SomaFM that is the whole `channels.json` roster
|
||||||
listener counts, genre search.
|
(≈50 channels), live listener counts and genre search.
|
||||||
- **Favourites** that float to the top, remembered between runs.
|
- **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
|
- **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
|
`PlaybackStatus`, `Metadata` (title/artist/album/artUrl), `Volume`, and
|
||||||
`Play`/`Pause`/`PlayPause`/`Stop`/`Next`/`Previous`, so GNOME/KDE widgets,
|
`Play`/`Pause`/`PlayPause`/`Stop`/`Next`/`Previous`, so GNOME/KDE widgets,
|
||||||
lock screens, `playerctl` and the media keys pick it up. On macOS the audio
|
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
|
## 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
|
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`.
|
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.
|
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
|
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`.
|
analyser falls back to `FFT`.
|
||||||
|
|
||||||
Terminal image rendering uses [Pillow](https://python-pillow.org/) and is
|
Terminal image rendering uses [Pillow](https://python-pillow.org/) and is
|
||||||
implemented directly (Kitty TGP + iTerm2 OSC 1337) — no terminal probing.
|
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
|
## Run
|
||||||
|
|
||||||
From the repository root, sync the environment and start the app:
|
From the repository root, sync the environment and start the app:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync # create .venv and install exactly what uv.lock pins
|
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,
|
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
|
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
|
### 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:
|
Useful flags:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run somafm-tui --play # start the last station immediately
|
uv run beefy-player --provider somafm # pick a station catalogue
|
||||||
uv run somafm-tui --station defcon # pick a station
|
uv run beefy-player --play # start the last station immediately
|
||||||
uv run somafm-tui --volume 55
|
uv run beefy-player --station defcon # pick a station
|
||||||
uv run somafm-tui --offline # no network; cached/bundled stations
|
uv run beefy-player --volume 55
|
||||||
uv run somafm-tui --no-mpris # no MPRIS / Now Playing, no media keys
|
uv run beefy-player --offline # no network; cached/bundled stations
|
||||||
uv run somafm-tui --list # print station ids
|
uv run beefy-player --no-mpris # no MPRIS / Now Playing, no media keys
|
||||||
uv run somafm-tui --no-audio # UI demo without mpv/ffmpeg
|
uv run beefy-player --list # print station ids
|
||||||
uv run somafm-tui --spectrum-source monitor # force synced output capture
|
uv run beefy-player --no-audio # UI demo without mpv/ffmpeg
|
||||||
uv run somafm-tui --spectrum-source stream # force the muted ffmpeg tap
|
uv run beefy-player --spectrum-source monitor # force synced output capture
|
||||||
SOMAFM_IMAGES=0 uv run somafm-tui # force the ANSI artwork mosaic
|
uv run beefy-player --spectrum-source stream # force the muted ffmpeg tap
|
||||||
SOMAFM_IMAGES=tgp uv run somafm-tui # force Kitty/TGP artwork
|
BEEFY_IMAGES=0 uv run beefy-player # force the ANSI artwork mosaic
|
||||||
SOMAFM_IMAGES=iterm2 uv run somafm-tui # force iTerm2/WezTerm artwork
|
BEEFY_IMAGES=tgp uv run beefy-player # force Kitty/TGP artwork
|
||||||
|
BEEFY_IMAGES=iterm2 uv run beefy-player # force iTerm2/WezTerm artwork
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Environment variables are prefixed `BEEFY_`; the pre-rename `SOMAFM_` spellings
|
||||||
|
(`SOMAFM_IMAGES`, `SOMAFM_MPV_ARGS`, `SOMAFM_SPECTRUM_DEVICE`) are still
|
||||||
|
honoured, so an existing shell profile keeps working.
|
||||||
|
|
||||||
The UI wants roughly **84×24**; it also works smaller, just less pretty.
|
The UI wants roughly **84×24**; it also works smaller, just less pretty.
|
||||||
|
|
||||||
## Keys
|
## Keys
|
||||||
@@ -194,7 +246,7 @@ The UI wants roughly **84×24**; it also works smaller, just less pretty.
|
|||||||
| `/` | filter by title, genre, description |
|
| `/` | filter by title, genre, description |
|
||||||
| `esc` | clear filter / close search |
|
| `esc` | clear filter / close search |
|
||||||
| `v` | visualiser: mirror → bars → wave → waterfall → radial → braille |
|
| `v` | visualiser: mirror → bars → wave → waterfall → radial → braille |
|
||||||
| `r` | refresh channels + song history |
|
| `r` | refresh stations + song history |
|
||||||
| `g` / `G` | top / bottom |
|
| `g` / `G` | top / bottom |
|
||||||
| `?` | help |
|
| `?` | help |
|
||||||
| `q` | quit |
|
| `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)
|
## Desktop media widgets (MPRIS / Now Playing)
|
||||||
|
|
||||||
**Linux** — while playing, the app exports
|
**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
|
```bash
|
||||||
playerctl -l
|
playerctl -l
|
||||||
@@ -210,14 +263,15 @@ playerctl metadata --format '{{artist}} — {{title}} ({{album}})'
|
|||||||
playerctl play-pause
|
playerctl play-pause
|
||||||
```
|
```
|
||||||
|
|
||||||
`mpris:artUrl` is the track's album art when SomaFM provides it, otherwise the
|
`mpris:artUrl` is the track's album art when the provider supplies it,
|
||||||
channel logo. If there is no session bus (SSH without `dbus-run-session`,
|
otherwise the channel logo. If there is no session bus (SSH without
|
||||||
containers, …) MPRIS is skipped and the status bar shows `mpris off`.
|
`dbus-run-session`, containers, …) MPRIS is skipped and the status bar shows
|
||||||
|
`mpris off`.
|
||||||
|
|
||||||
**macOS** — mpv owns the system Now Playing session, so Control Center, the
|
**macOS** — mpv owns the system Now Playing session, so Control Center, the
|
||||||
menu-bar widget and the keyboard media keys (▶︎/⏸, ⏭, ⏮) all target this app
|
menu-bar widget and the keyboard media keys (▶︎/⏸, ⏭, ⏮) all target this app
|
||||||
while it is playing. mpv publishes the live stream title (the same
|
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
|
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
|
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
|
`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
|
## 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
|
- `channels.json` → stations, genres, listeners, `.pls` playlist URLs
|
||||||
- `<station>.pls` → resolved direct icecast URLs (cached 8h)
|
- `<station>.pls` → resolved direct icecast URLs (cached 8h)
|
||||||
- `songs/<station>.json` → last played tracks (cached on disk)
|
- `songs/<station>.json` → last played tracks (cached on disk)
|
||||||
Playlist and channel caching means the UI opens instantly and still works
|
Playlist and channel caching means the UI opens instantly and still works
|
||||||
offline.
|
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
|
`pause`/`volume`, handles mirror failover on `end-file`, auto-restarts a dead
|
||||||
engine, and uses `SIGSTOP`/`SIGCONT` when falling back to `ffplay`. On macOS
|
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
|
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.
|
`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
|
the device's native float32 rate from the macOS tap). Prefers capturing the
|
||||||
real output: `parec` (PulseAudio/PipeWire monitor) on Linux, or a native
|
real output: `parec` (PulseAudio/PipeWire monitor) on Linux, or a native
|
||||||
Core Audio process tap on macOS (SYNC, see `macos_tap.py`). Otherwise a
|
Core Audio process tap on macOS (SYNC, see `macos_tap.py`). Otherwise a
|
||||||
muted low-latency `ffmpeg` decode of the stream (FFT). Hann-windowed
|
muted low-latency `ffmpeg` decode of the stream (FFT). Hann-windowed
|
||||||
2048-point FFT, log band grouping, auto-gain, fast attack/decay smoothing,
|
2048-point FFT, log band grouping, auto-gain, fast attack/decay smoothing,
|
||||||
peak hold and waveform downsampling.
|
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` →
|
against Core Audio: `CATapDescription` → `AudioHardwareCreateProcessTap` →
|
||||||
private aggregate device → IOProc. No driver, no helper binary, no ffmpeg.
|
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.
|
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).
|
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
|
the now-playing track (artist + track first, then artist + album, then the
|
||||||
album page) and returns a track URL when there is a match.
|
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
|
backgrounds are `ansi_default` (`SGR 49`), so transparent terminals stay
|
||||||
transparent.
|
transparent.
|
||||||
|
|
||||||
### Files
|
### Files
|
||||||
|
|
||||||
```
|
```
|
||||||
~/.config/somafm-tui/config.json favourites, volume, last station, mode
|
~/.config/beefy-player/config.json favourites, volume, last station, mode
|
||||||
~/.cache/somafm-tui/channels.json last good station list
|
~/.cache/beefy-player/channels.json last good station list
|
||||||
~/.cache/somafm-tui/playlists.json resolved stream URLs
|
~/.cache/beefy-player/playlists.json resolved stream URLs
|
||||||
~/.cache/somafm-tui/songs/<id>.json song history snapshots
|
~/.cache/beefy-player/songs/<id>.json song history snapshots
|
||||||
~/.cache/somafm-tui/artwork/<sha1> cached album/channel artwork
|
~/.cache/beefy-player/artwork/<sha1> cached album/channel artwork
|
||||||
```
|
```
|
||||||
|
|
||||||
These stay in the XDG locations on every platform (macOS included), so the
|
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
|
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
|
## Notes / troubleshooting
|
||||||
|
|
||||||
- **Artwork shows the mosaic?** Real images are auto-enabled for Kitty,
|
- **Artwork shows the mosaic?** Real images are auto-enabled for Kitty,
|
||||||
iTerm2 and WezTerm. Force a protocol with `SOMAFM_IMAGES=tgp` (Kitty/TGP) or
|
iTerm2 and WezTerm. Force a protocol with `BEEFY_IMAGES=tgp` (Kitty/TGP) or
|
||||||
`SOMAFM_IMAGES=iterm2` (iTerm2/WezTerm); `SOMAFM_IMAGES=0` forces the ANSI
|
`BEEFY_IMAGES=iterm2` (iTerm2/WezTerm); `BEEFY_IMAGES=0` forces the ANSI
|
||||||
mosaic. iTerm2 on macOS is detected from `TERM_PROGRAM`/`LC_TERMINAL`, and
|
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
|
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
|
ignored so they cannot downgrade it. Kitty inside tmux is attempted through
|
||||||
tmux passthrough (enable `set -g allow-passthrough on`); iTerm2/WezTerm
|
tmux passthrough (enable `set -g allow-passthrough on`); iTerm2/WezTerm
|
||||||
inside tmux default to the mosaic. Images are cached under
|
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
|
- **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
|
`/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
|
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.
|
launches the app.
|
||||||
- **MPRIS says off/error?** No session bus. Run from your desktop session
|
- **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
|
(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.
|
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
|
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).
|
macOS way to choose an output device).
|
||||||
- **Spectrum says `FFT` instead of `SYNC`?** The monitor is unavailable or
|
- **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
|
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
|
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.
|
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
|
detected protocol, widget class, terminal env, dependency versions and cache
|
||||||
path. If `protocol` is `None`, force it with `SOMAFM_IMAGES=tgp` (Kitty) or
|
path. If `protocol` is `None`, force it with `BEEFY_IMAGES=tgp` (Kitty) or
|
||||||
`SOMAFM_IMAGES=iterm2` (iTerm2/WezTerm).
|
`BEEFY_IMAGES=iterm2` (iTerm2/WezTerm).
|
||||||
- **`SYNC` but the bars are flat?** mpv may be playing to a different sink (or
|
- **`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`.
|
use `--spectrum-source stream`.
|
||||||
- **macOS: spectrum stays `FFT`?** Grant your terminal **System Audio
|
- **macOS: spectrum stays `FFT`?** Grant your terminal **System Audio
|
||||||
Recording** permission (*System Settings → Privacy & Security → System Audio
|
Recording** permission (*System Settings → Privacy & Security → System Audio
|
||||||
@@ -340,7 +419,7 @@ manylinux wheels such as NumPy that expect `libstdc++.so.6` in the usual
|
|||||||
```bash
|
```bash
|
||||||
nix-shell # enter the FHS dev shell
|
nix-shell # enter the FHS dev shell
|
||||||
uv sync
|
uv sync
|
||||||
uv run somafm-tui
|
uv run beefy-player
|
||||||
```
|
```
|
||||||
|
|
||||||
- If you see `libstdc++.so.6: cannot open shared object file`, make sure you
|
- 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.
|
"""Terminal artwork support.
|
||||||
|
|
||||||
The TUI draws a procedural ANSI mosaic when the terminal cannot display real
|
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
|
images. On terminals with a graphics protocol we fetch the station's album
|
||||||
(or channel logo) and render it directly, with the mosaic kept behind it:
|
art (or channel logo) and render it directly, with the mosaic behind it:
|
||||||
|
|
||||||
* Kitty -> Terminal Graphics Protocol (direct image placement)
|
* Kitty -> Terminal Graphics Protocol (direct image placement)
|
||||||
* iTerm2 / WezTerm -> native iTerm2 inline images (OSC 1337)
|
* iTerm2 / WezTerm -> native iTerm2 inline images (OSC 1337)
|
||||||
|
|
||||||
The protocols are implemented directly; there is no terminal capability probe
|
The protocols are implemented directly; there is no terminal capability probe
|
||||||
and no extra terminal input, so nothing leaks into the shell at exit.
|
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`` /
|
Set ``BEEFY_IMAGES=0`` to force the ANSI fallback, or ``BEEFY_IMAGES=tgp`` /
|
||||||
``SOMAFM_IMAGES=iterm2`` to force a protocol.
|
``BEEFY_IMAGES=iterm2`` to force a protocol.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
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
|
env = os.environ if environ is None else environ
|
||||||
if "NO_COLOR" in env:
|
if "NO_COLOR" in env:
|
||||||
return None
|
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:
|
if override in _OFF_VALUES:
|
||||||
return None
|
return None
|
||||||
if override in _PROTOCOL_VALUES:
|
if override in _PROTOCOL_VALUES:
|
||||||
@@ -154,7 +156,7 @@ def cell_size() -> tuple[int, int]:
|
|||||||
|
|
||||||
def _cache_path(url: str):
|
def _cache_path(url: str):
|
||||||
digest = hashlib.sha1(url.encode("utf-8")).hexdigest()
|
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:
|
def load_cached(url: str) -> bytes | None:
|
||||||
@@ -197,13 +199,13 @@ def diagnostics() -> dict[str, object]:
|
|||||||
"ITERM_SESSION_ID": env.get("ITERM_SESSION_ID", ""),
|
"ITERM_SESSION_ID": env.get("ITERM_SESSION_ID", ""),
|
||||||
"TMUX": env.get("TMUX", ""),
|
"TMUX": env.get("TMUX", ""),
|
||||||
"ZELLIJ": env.get("ZELLIJ", ""),
|
"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 "",
|
"NO_COLOR": "set" if "NO_COLOR" in env else "",
|
||||||
"protocol": PROTOCOL,
|
"protocol": PROTOCOL,
|
||||||
"supported": supported(),
|
"supported": supported(),
|
||||||
"cell_size": cell_size(),
|
"cell_size": cell_size(),
|
||||||
"pillow": pkg_version("pillow"),
|
"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:
|
Everything lives in the usual XDG places:
|
||||||
|
|
||||||
~/.config/somafm-tui/config.json favourites, volume, last station
|
~/.config/beefy-player/config.json favourites, volume, station
|
||||||
~/.cache/somafm-tui/channels.json last good copy of somafm.com/channels.json
|
~/.cache/beefy-player/channels.json last good station list
|
||||||
~/.cache/somafm-tui/playlists.json resolved .pls -> stream URLs
|
~/.cache/beefy-player/playlists.json resolved .pls -> stream URLs
|
||||||
~/.cache/somafm-tui/songs/<id>.json last good copy of the song history
|
~/.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
|
from __future__ import annotations
|
||||||
@@ -13,11 +17,26 @@ from __future__ import annotations
|
|||||||
import contextlib
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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:
|
def _xdg(env_var: str, fallback: str) -> Path:
|
||||||
@@ -27,6 +46,14 @@ def _xdg(env_var: str, fallback: str) -> Path:
|
|||||||
return Path.home() / fallback
|
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
|
CONFIG_DIR = _xdg("XDG_CONFIG_HOME", ".config") / APP_NAME
|
||||||
CACHE_DIR = _xdg("XDG_CACHE_HOME", ".cache") / APP_NAME
|
CACHE_DIR = _xdg("XDG_CACHE_HOME", ".cache") / APP_NAME
|
||||||
RUNTIME_DIR = Path(os.environ.get("XDG_RUNTIME_DIR") or "/tmp") / 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"
|
CHANNELS_CACHE = CACHE_DIR / "channels.json"
|
||||||
PLAYLIST_CACHE = CACHE_DIR / "playlists.json"
|
PLAYLIST_CACHE = CACHE_DIR / "playlists.json"
|
||||||
SONGS_CACHE_DIR = CACHE_DIR / "songs"
|
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")
|
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))
|
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)
|
@dataclass(slots=True)
|
||||||
class Config:
|
class Config:
|
||||||
favorites: list[str] = field(default_factory=list)
|
favorites: list[str] = field(default_factory=list)
|
||||||
@@ -72,6 +118,8 @@ class Config:
|
|||||||
volume: int = 70
|
volume: int = 70
|
||||||
visualizer: str = "both"
|
visualizer: str = "both"
|
||||||
autoplay: bool = False
|
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,
|
#: 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
|
#: macOS Now Playing via mpv) and honour the media keys. The JSON key is
|
||||||
#: spelled ``mpris`` for backwards compatibility.
|
#: spelled ``mpris`` for backwards compatibility.
|
||||||
@@ -79,6 +127,7 @@ class Config:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls) -> Config:
|
def load(cls) -> Config:
|
||||||
|
migrate_legacy_state()
|
||||||
raw = read_json(CONFIG_PATH)
|
raw = read_json(CONFIG_PATH)
|
||||||
config = cls()
|
config = cls()
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
@@ -100,6 +149,10 @@ class Config:
|
|||||||
if visualizer in VISUALIZERS:
|
if visualizer in VISUALIZERS:
|
||||||
config.visualizer = visualizer
|
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):
|
if isinstance(raw.get("autoplay"), bool):
|
||||||
config.autoplay = raw["autoplay"]
|
config.autoplay = raw["autoplay"]
|
||||||
if isinstance(raw.get("mpris"), bool):
|
if isinstance(raw.get("mpris"), bool):
|
||||||
@@ -407,7 +407,7 @@ class SystemAudioTap:
|
|||||||
if not nsuuid:
|
if not nsuuid:
|
||||||
raise TapError("could not create the tap UUID")
|
raise TapError("could not create the tap UUID")
|
||||||
_send_object(description, b"setUUID:", nsuuid)
|
_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)
|
_send_flag(description, b"setPrivate:", True)
|
||||||
# 0 == CATapUnmuted: the audio we record keeps playing as usual.
|
# 0 == CATapUnmuted: the audio we record keeps playing as usual.
|
||||||
_send_integer(description, b"setMuteBehavior:", 0)
|
_send_integer(description, b"setMuteBehavior:", 0)
|
||||||
@@ -460,7 +460,7 @@ class SystemAudioTap:
|
|||||||
owned.append(aggregate)
|
owned.append(aggregate)
|
||||||
for key, value in (
|
for key, value in (
|
||||||
(_AGGREGATE_UID, cfstr(str(uuid.uuid4()).upper())),
|
(_AGGREGATE_UID, cfstr(str(uuid.uuid4()).upper())),
|
||||||
(_AGGREGATE_NAME, cfstr("somafm-tui tap")),
|
(_AGGREGATE_NAME, cfstr("beefy-player tap")),
|
||||||
(_AGGREGATE_PRIVATE, _boolean(True)),
|
(_AGGREGATE_PRIVATE, _boolean(True)),
|
||||||
(_AGGREGATE_STACKED, _boolean(False)),
|
(_AGGREGATE_STACKED, _boolean(False)),
|
||||||
(_AGGREGATE_TAPS, tap_list),
|
(_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:
|
which keeps the platform differences in one place:
|
||||||
|
|
||||||
* **Linux / BSD / any D-Bus desktop** — :class:`somafm_tui.mpris.MprisService`
|
* **Linux / BSD / any D-Bus desktop** — :class:`beefy_player.mpris.MprisService`
|
||||||
exports ``org.mpris.MediaPlayer2.somafmtui.instance<pid>`` on the session
|
exports ``org.mpris.MediaPlayer2.beefyplayer.instance<pid>`` on the session
|
||||||
bus, so GNOME/KDE widgets, lock screens and ``playerctl`` show the station
|
bus, so GNOME/KDE widgets, lock screens and ``playerctl`` show the station
|
||||||
and can drive playback.
|
and can drive playback.
|
||||||
* **macOS** — mpv *is* the app the system knows about: its Cocoa backend
|
* **macOS** — mpv *is* the app the system knows about: its Cocoa backend
|
||||||
registers with ``MPRemoteCommandCenter``/``MPNowPlayingInfoCenter``, which
|
registers with ``MPRemoteCommandCenter``/``MPNowPlayingInfoCenter``, which
|
||||||
is what Control Center, the menu bar and the media keys talk to. So there
|
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
|
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.
|
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
|
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.
|
"""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
|
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
|
those key presses back into this app. ``available`` is false when the audio
|
||||||
backend is not mpv, because then nobody publishes anything.
|
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")
|
(track_key or f"{station}|{artist}|{title}").encode("utf-8")
|
||||||
).hexdigest()[:24]
|
).hexdigest()[:24]
|
||||||
metadata: dict[str, Any] = {
|
metadata: dict[str, Any] = {
|
||||||
"mpris:trackid": Variant("o", f"/org/somafm/track/{digest}"),
|
"mpris:trackid": Variant("o", f"/org/beefyplayer/track/{digest}"),
|
||||||
"xesam:title": Variant("s", title or station or "SomaFM"),
|
"xesam:title": Variant("s", title or station or "Beefy Player"),
|
||||||
"xesam:artist": Variant("as", [artist] if artist else ["SomaFM"]),
|
"xesam:artist": Variant("as", [artist] if artist else ["Beefy Player"]),
|
||||||
"xesam:comment": Variant("as", [f"SomaFM · {station}"] if station else ["SomaFM"]),
|
"xesam:comment": Variant(
|
||||||
|
"as", [f"Beefy Player · {station}"] if station else ["Beefy Player"]
|
||||||
|
),
|
||||||
}
|
}
|
||||||
if album:
|
if album:
|
||||||
metadata["xesam:album"] = Variant("s", album)
|
metadata["xesam:album"] = Variant("s", album)
|
||||||
@@ -125,11 +127,11 @@ class _RootInterface(ServiceInterface):
|
|||||||
|
|
||||||
@dbus_property(name="Identity", access=PropertyAccess.READ)
|
@dbus_property(name="Identity", access=PropertyAccess.READ)
|
||||||
def Identity(self) -> "s":
|
def Identity(self) -> "s":
|
||||||
return "SomaFM TUI"
|
return "Beefy Player"
|
||||||
|
|
||||||
@dbus_property(name="DesktopEntry", access=PropertyAccess.READ)
|
@dbus_property(name="DesktopEntry", access=PropertyAccess.READ)
|
||||||
def DesktopEntry(self) -> "s":
|
def DesktopEntry(self) -> "s":
|
||||||
return "somafm-tui"
|
return "beefy-player"
|
||||||
|
|
||||||
@dbus_property(name="CanQuit", access=PropertyAccess.READ)
|
@dbus_property(name="CanQuit", access=PropertyAccess.READ)
|
||||||
def CanQuit(self) -> "b":
|
def CanQuit(self) -> "b":
|
||||||
@@ -284,7 +286,7 @@ class _PlayerInterface(ServiceInterface):
|
|||||||
|
|
||||||
|
|
||||||
class MprisService:
|
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"
|
label = "mpris"
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -306,7 +308,7 @@ class MprisService:
|
|||||||
self._status = "Stopped"
|
self._status = "Stopped"
|
||||||
self._metadata: dict[str, Any] = {}
|
self._metadata: dict[str, Any] = {}
|
||||||
self.bus_name = (
|
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:
|
if not self.enabled:
|
||||||
return
|
return
|
||||||
self._thread = threading.Thread(
|
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()
|
self._thread.start()
|
||||||
|
|
||||||
@@ -28,8 +28,7 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .api import USER_AGENT
|
from .config import RUNTIME_DIR, USER_AGENT, atomic_write_text, env_var
|
||||||
from .config import RUNTIME_DIR, atomic_write_text
|
|
||||||
|
|
||||||
#: mpv input bindings that turn the system media keys into TUI commands.
|
#: 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
|
#: list, the spectrum and the desktop "now playing" state) we bounce them back
|
||||||
#: over the IPC socket as ``client-message`` events.
|
#: over the IPC socket as ``client-message`` events.
|
||||||
MEDIA_KEY_BINDINGS = """\
|
MEDIA_KEY_BINDINGS = """\
|
||||||
# Generated by somafm-tui -- do not edit; it is rewritten on every launch.
|
# Generated by beefy-player -- do not edit; it is rewritten on every launch.
|
||||||
PLAY script-message somafm toggle
|
PLAY script-message beefy toggle
|
||||||
PLAYONLY script-message somafm play
|
PLAYONLY script-message beefy play
|
||||||
PAUSEONLY script-message somafm pause
|
PAUSEONLY script-message beefy pause
|
||||||
STOP script-message somafm stop
|
STOP script-message beefy stop
|
||||||
NEXT script-message somafm next
|
NEXT script-message beefy next
|
||||||
PREV script-message somafm prev
|
PREV script-message beefy prev
|
||||||
"""
|
"""
|
||||||
|
|
||||||
#: Commands a media key may ask for; everything else is ignored.
|
#: Commands a media key may ask for; everything else is ignored.
|
||||||
MEDIA_COMMANDS = frozenset({"play", "pause", "toggle", "stop", "next", "prev"})
|
MEDIA_COMMANDS = frozenset({"play", "pause", "toggle", "stop", "next", "prev"})
|
||||||
|
|
||||||
#: Prefix of the ``client-message`` namespace we listen on.
|
#: Prefix of the ``client-message`` namespace we listen on.
|
||||||
MEDIA_MESSAGE_PREFIX = "somafm"
|
MEDIA_MESSAGE_PREFIX = "beefy"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -113,7 +112,7 @@ class Player:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
self._thread = threading.Thread(
|
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._thread.start()
|
||||||
self._ready.wait(10)
|
self._ready.wait(10)
|
||||||
@@ -316,12 +315,12 @@ class Player:
|
|||||||
"--cache=yes",
|
"--cache=yes",
|
||||||
"--cache-secs=20",
|
"--cache-secs=20",
|
||||||
"--demuxer-max-bytes=32MiB",
|
"--demuxer-max-bytes=32MiB",
|
||||||
"--audio-client-name=somafm-tui",
|
"--audio-client-name=beefy-player",
|
||||||
f"--user-agent={USER_AGENT}",
|
f"--user-agent={USER_AGENT}",
|
||||||
"--network-timeout=20",
|
"--network-timeout=20",
|
||||||
]
|
]
|
||||||
command.extend(self._platform_options())
|
command.extend(self._platform_options())
|
||||||
extra = os.environ.get("SOMAFM_MPV_ARGS")
|
extra = env_var("MPV_ARGS")
|
||||||
if extra:
|
if extra:
|
||||||
command.extend(shlex.split(extra))
|
command.extend(shlex.split(extra))
|
||||||
|
|
||||||
@@ -466,7 +465,7 @@ class Player:
|
|||||||
self.events.put(PlayerEvent(kind="notice", detail=str(error)))
|
self.events.put(PlayerEvent(kind="notice", detail=str(error)))
|
||||||
|
|
||||||
def _handle_client_message(self, args: Any) -> None:
|
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
|
mpv's macOS backend feeds the system media keys (and Control Center
|
||||||
buttons) into its input system; ``MEDIA_KEY_BINDINGS`` maps them onto
|
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:
|
That capture is platform specific:
|
||||||
|
|
||||||
* **Linux** — ``parec`` records the PulseAudio/PipeWire monitor of the default
|
* **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
|
* **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
|
loopback, no Multi-Output Device, no helper binary. macOS asks for
|
||||||
**System Audio Recording** permission the first time; while it is missing
|
**System Audio Recording** permission the first time; while it is missing
|
||||||
the tap delivers silence, which is treated as a failed capture.
|
the tap delivers silence, which is treated as a failed capture.
|
||||||
@@ -35,7 +35,7 @@ from dataclasses import dataclass
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from . import macos_tap
|
from . import macos_tap
|
||||||
from .api import USER_AGENT
|
from .config import USER_AGENT, env_var
|
||||||
|
|
||||||
SAMPLE_RATE = 44100
|
SAMPLE_RATE = 44100
|
||||||
FFT_SIZE = 2048
|
FFT_SIZE = 2048
|
||||||
@@ -164,7 +164,7 @@ class SpectrumEngine:
|
|||||||
if self._thread is not None:
|
if self._thread is not None:
|
||||||
return
|
return
|
||||||
self._thread = threading.Thread(
|
self._thread = threading.Thread(
|
||||||
target=self._run, name="somafm-spectrum", daemon=True
|
target=self._run, name="beefy-player-spectrum", daemon=True
|
||||||
)
|
)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
@@ -315,7 +315,7 @@ class SpectrumEngine:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _monitor_device(self) -> str:
|
def _monitor_device(self) -> str:
|
||||||
override = os.environ.get("SOMAFM_SPECTRUM_DEVICE", "").strip()
|
override = env_var("SPECTRUM_DEVICE")
|
||||||
if override:
|
if override:
|
||||||
return override
|
return override
|
||||||
if self._monitor_device_cache is not None:
|
if self._monitor_device_cache is not None:
|
||||||
@@ -351,7 +351,7 @@ class SpectrumEngine:
|
|||||||
"--channels=1",
|
"--channels=1",
|
||||||
"--raw",
|
"--raw",
|
||||||
"--latency-msec=30",
|
"--latency-msec=30",
|
||||||
"--client-name=somafm-tui",
|
"--client-name=beefy-player",
|
||||||
]
|
]
|
||||||
|
|
||||||
def _spawn(
|
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. */
|
Colours are ANSI names only; this file never paints a background. */
|
||||||
|
|
||||||
Screen {
|
Screen {
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Custom Textual widgets for Beefy Player."""
|
||||||
@@ -28,7 +28,7 @@ _NULL_STYLE = Style()
|
|||||||
_NULL_CONTROL = ((ControlType.CURSOR_FORWARD, 0),)
|
_NULL_CONTROL = ((ControlType.CURSOR_FORWARD, 0),)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..app import SomaFMApp
|
from ..app import BeefyPlayerApp
|
||||||
|
|
||||||
|
|
||||||
_IMAGE_IDS = itertools.count(random.randrange(1, 2**31 - 1))
|
_IMAGE_IDS = itertools.count(random.randrange(1, 2**31 - 1))
|
||||||
@@ -314,7 +314,7 @@ class ArtPane(Widget):
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def render(self) -> RenderableType:
|
def render(self) -> RenderableType:
|
||||||
app = cast("SomaFMApp", self.app)
|
app = cast("BeefyPlayerApp", self.app)
|
||||||
station = app.current
|
station = app.current
|
||||||
if station is None:
|
if station is None:
|
||||||
return Text("")
|
return Text("")
|
||||||
@@ -341,7 +341,7 @@ class ArtPane(Widget):
|
|||||||
# Keep the mosaic behind the image in sync with the current track.
|
# Keep the mosaic behind the image in sync with the current track.
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
app = cast("SomaFMApp", self.app)
|
app = cast("BeefyPlayerApp", self.app)
|
||||||
station = app.current
|
station = app.current
|
||||||
data = app.artwork_bytes
|
data = app.artwork_bytes
|
||||||
width = max(1, self.size.width)
|
width = max(1, self.size.width)
|
||||||
File renamed without changes.
@@ -19,7 +19,7 @@ from .common import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..app import SomaFMApp
|
from ..app import BeefyPlayerApp
|
||||||
|
|
||||||
LOGO = (
|
LOGO = (
|
||||||
"▄▀▀▀ ▄▀▀▄ █▄ ▄█ ▄▀▀▄",
|
"▄▀▀▀ ▄▀▀▄ █▄ ▄█ ▄▀▀▄",
|
||||||
@@ -34,7 +34,7 @@ class HeaderBar(Widget):
|
|||||||
can_focus = False
|
can_focus = False
|
||||||
|
|
||||||
def render(self) -> RenderableType:
|
def render(self) -> RenderableType:
|
||||||
app = cast("SomaFMApp", self.app)
|
app = cast("BeefyPlayerApp", self.app)
|
||||||
width = max(24, self.size.width)
|
width = max(24, self.size.width)
|
||||||
height = max(1, self.size.height)
|
height = max(1, self.size.height)
|
||||||
state = app.playing_state
|
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
|
from .common import LEVELS, SPECTRUM_STOPS, gradient
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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
|
#: 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.
|
#: history at 20 Hz, plenty for even a very wide terminal.
|
||||||
@@ -62,7 +62,7 @@ class SpectrumPanel(Widget):
|
|||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
def render(self) -> RenderableType:
|
def render(self) -> RenderableType:
|
||||||
app = cast("SomaFMApp", self.app)
|
app = cast("BeefyPlayerApp", self.app)
|
||||||
frame = self.engine.snapshot()
|
frame = self.engine.snapshot()
|
||||||
width = max(8, self.size.width)
|
width = max(8, self.size.width)
|
||||||
height = max(2, self.size.height)
|
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
|
from .common import compact_count, truncate
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..app import SomaFMApp
|
from ..app import BeefyPlayerApp
|
||||||
|
|
||||||
|
|
||||||
class StationList(Widget):
|
class StationList(Widget):
|
||||||
@@ -29,13 +29,13 @@ class StationList(Widget):
|
|||||||
self.border_title = title
|
self.border_title = title
|
||||||
|
|
||||||
def on_mouse_scroll_down(self, event) -> None:
|
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:
|
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:
|
def render(self) -> RenderableType:
|
||||||
app = cast("SomaFMApp", self.app)
|
app = cast("BeefyPlayerApp", self.app)
|
||||||
width = max(12, self.size.width)
|
width = max(12, self.size.width)
|
||||||
height = max(1, self.size.height)
|
height = max(1, self.size.height)
|
||||||
view = app.view
|
view = app.view
|
||||||
@@ -12,7 +12,7 @@ from textual.widget import Widget
|
|||||||
from .common import compact_count, state_glyph, truncate
|
from .common import compact_count, state_glyph, truncate
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..app import SomaFMApp
|
from ..app import BeefyPlayerApp
|
||||||
|
|
||||||
HINTS_LONG = (
|
HINTS_LONG = (
|
||||||
"SPACE pause ↑↓ station ENTER tune +/- vol f ★ / filter "
|
"SPACE pause ↑↓ station ENTER tune +/- vol f ★ / filter "
|
||||||
@@ -28,7 +28,7 @@ class StatusBar(Widget):
|
|||||||
can_focus = False
|
can_focus = False
|
||||||
|
|
||||||
def render(self) -> RenderableType:
|
def render(self) -> RenderableType:
|
||||||
app = cast("SomaFMApp", self.app)
|
app = cast("BeefyPlayerApp", self.app)
|
||||||
width = max(20, self.size.width)
|
width = max(20, self.size.width)
|
||||||
glyph, colour = state_glyph(app.playing_state)
|
glyph, colour = state_glyph(app.playing_state)
|
||||||
frame = app.spectrum.snapshot()
|
frame = app.spectrum.snapshot()
|
||||||
+9
-9
@@ -1,13 +1,13 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "somafm-tui"
|
name = "beefy-player"
|
||||||
version = "0.1.0"
|
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"
|
readme = "README.md"
|
||||||
license = "WTFPL"
|
license = "WTFPL"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
authors = [{ name = "somafm-tui contributors" }]
|
authors = [{ name = "beefy-player contributors" }]
|
||||||
keywords = ["somafm", "radio", "tui", "terminal", "mpris", "audio"]
|
keywords = ["radio", "internet-radio", "tui", "terminal", "mpris", "audio", "somafm"]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Environment :: Console",
|
"Environment :: Console",
|
||||||
"Intended Audience :: End Users/Desktop",
|
"Intended Audience :: End Users/Desktop",
|
||||||
@@ -31,17 +31,17 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
somafm-tui = "somafm_tui.__main__:main"
|
beefy-player = "beefy_player.__main__:main"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["somafm_tui"]
|
packages = ["beefy_player"]
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel.force-include]
|
[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]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -60,8 +60,8 @@ extend-exclude = ["dist", ".venv", "build"]
|
|||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E4", "E7", "E9", "F", "I", "UP", "B", "SIM", "RUF"]
|
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
|
# dbus-next needs short signature strings ("s", "a{sv}", "x", …) in
|
||||||
# annotations; they are not Python names.
|
# annotations; they are not Python names.
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[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> { } }:
|
{ 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
|
# 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).
|
# libstdc++.so.6 and friends in the usual FHS locations (/usr/lib, /lib).
|
||||||
@@ -12,13 +12,13 @@
|
|||||||
# nix-shell # enter the FHS shell
|
# nix-shell # enter the FHS shell
|
||||||
# rm -rf .venv # once, if the venv was created by an older setup
|
# rm -rf .venv # once, if the venv was created by an older setup
|
||||||
# uv sync # install Python deps with uv
|
# 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
|
# Note: `nix-shell --run "..."` is not supported by FHS shell.nix files; enter
|
||||||
# the shell interactively and run commands there.
|
# the shell interactively and run commands there.
|
||||||
|
|
||||||
(pkgs.buildFHSEnv {
|
(pkgs.buildFHSEnv {
|
||||||
name = "somafm-tui";
|
name = "beefy-player";
|
||||||
|
|
||||||
targetPkgs = pkgs: with pkgs; [
|
targetPkgs = pkgs: with pkgs; [
|
||||||
# --- Python toolchain; uv owns all Python dependencies ---------------
|
# --- Python toolchain; uv owns all Python dependencies ---------------
|
||||||
@@ -83,13 +83,13 @@
|
|||||||
export DBUS_SESSION_BUS_ADDRESS="unix:path=$XDG_RUNTIME_DIR/bus"
|
export DBUS_SESSION_BUS_ADDRESS="unix:path=$XDG_RUNTIME_DIR/bus"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
export PS1='\[\e[1;35m\][somafm-tui]\[\e[0m\] \w \$ '
|
export PS1='\[\e[1;35m\][beefy-player]\[\e[0m\] \w \$ '
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " somafm-tui — FHS dev shell"
|
echo " beefy-player — FHS dev shell"
|
||||||
echo " --------------------------"
|
echo " ----------------------------"
|
||||||
echo " uv sync install / update python deps"
|
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 ""
|
||||||
echo " python : $(python3 --version 2>&1)"
|
echo " python : $(python3 --version 2>&1)"
|
||||||
echo " uv : $(uv --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
|
import pytest
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from somafm_tui import artwork
|
from beefy_player import artwork
|
||||||
from somafm_tui.app import SomaFMApp
|
from beefy_player.app import BeefyPlayerApp
|
||||||
from somafm_tui.widgets import art as art_widgets
|
from beefy_player.widgets import art as art_widgets
|
||||||
from somafm_tui.widgets.art import ArtPane, ITerm2Image, KittyImage
|
from beefy_player.widgets.art import ArtPane, ITerm2Image, KittyImage
|
||||||
from somafm_tui.widgets.stage import StagePanel
|
from beefy_player.widgets.stage import StagePanel
|
||||||
|
|
||||||
|
|
||||||
async def _no_fetch(client, url: str) -> None:
|
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, "load_cached", lambda url: None)
|
||||||
monkeypatch.setattr(artwork, "fetch", _no_fetch)
|
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:
|
async with app.run_test(size=(110, 40)) as pilot:
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
pane = app.query_one(ArtPane)
|
pane = app.query_one(ArtPane)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import httpx
|
|||||||
import pytest
|
import pytest
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from somafm_tui import artwork
|
from beefy_player import artwork
|
||||||
|
|
||||||
|
|
||||||
def png_bytes(size=(400, 200)) -> bytes:
|
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({"ZELLIJ": "1", "TERM_PROGRAM": "WezTerm"}) is None
|
||||||
assert artwork.detect_protocol({"KITTY_PID": "1"}) == "tgp"
|
assert artwork.detect_protocol({"KITTY_PID": "1"}) == "tgp"
|
||||||
assert artwork.detect_protocol({"NO_COLOR": "", "KITTY_WINDOW_ID": "1"}) is None
|
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({"BEEFY_IMAGES": "0", "KITTY_WINDOW_ID": "1"}) is None
|
||||||
assert artwork.detect_protocol({"SOMAFM_IMAGES": "tgp"}) == "tgp"
|
assert artwork.detect_protocol({"BEEFY_IMAGES": "tgp"}) == "tgp"
|
||||||
assert artwork.detect_protocol({"SOMAFM_IMAGES": "iterm2"}) == "iterm2"
|
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:
|
def test_fit_image_crops_to_the_cell_box() -> None:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import json
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from somafm_tui import bandcamp
|
from beefy_player import bandcamp
|
||||||
|
|
||||||
TRACK_PAYLOAD = {
|
TRACK_PAYLOAD = {
|
||||||
"auto": {
|
"auto": {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import types
|
|||||||
import pytest
|
import pytest
|
||||||
from rich.cells import cell_len
|
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$")
|
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}$")
|
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
|
import pytest
|
||||||
|
|
||||||
from somafm_tui import macos_tap
|
from beefy_player import macos_tap
|
||||||
|
|
||||||
|
|
||||||
def test_osstatus_decodes_four_character_codes() -> None:
|
def test_osstatus_decodes_four_character_codes() -> None:
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ from __future__ import annotations
|
|||||||
import queue
|
import queue
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from somafm_tui import mediasession
|
from beefy_player import mediasession
|
||||||
from somafm_tui.mediasession import (
|
from beefy_player.mediasession import (
|
||||||
MediaSession,
|
MediaSession,
|
||||||
NowPlayingSession,
|
NowPlayingSession,
|
||||||
create_media_session,
|
create_media_session,
|
||||||
@@ -12,7 +12,7 @@ from somafm_tui.mediasession import (
|
|||||||
|
|
||||||
|
|
||||||
def test_linux_uses_mpris() -> None:
|
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")
|
session = create_media_session(queue.Queue(), enabled=False, platform="linux")
|
||||||
assert isinstance(session, MprisService)
|
assert isinstance(session, MprisService)
|
||||||
|
|||||||
+4
-4
@@ -4,7 +4,7 @@ import queue
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from somafm_tui import mpris
|
from beefy_player import mpris
|
||||||
|
|
||||||
|
|
||||||
def test_build_metadata() -> None:
|
def test_build_metadata() -> None:
|
||||||
@@ -17,16 +17,16 @@ def test_build_metadata() -> None:
|
|||||||
stream_url="https://ice2.somafm.com/groovesalad-256-mp3",
|
stream_url="https://ice2.somafm.com/groovesalad-256-mp3",
|
||||||
track_key="groovesalad|Jens Buchert|Rauschebart|Gravity",
|
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:title"].value == "Rauschebart"
|
||||||
assert metadata["xesam:artist"].value == ["Jens Buchert"]
|
assert metadata["xesam:artist"].value == ["Jens Buchert"]
|
||||||
assert metadata["xesam:album"].value == "Gravity"
|
assert metadata["xesam:album"].value == "Gravity"
|
||||||
assert metadata["mpris:artUrl"].value == "https://example.com/art.jpg"
|
assert metadata["mpris:artUrl"].value == "https://example.com/art.jpg"
|
||||||
assert metadata["xesam:url"].value.startswith("https://ice2.")
|
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="")
|
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 "xesam:album" not in minimal
|
||||||
assert "mpris:artUrl" not in minimal
|
assert "mpris:artUrl" not in minimal
|
||||||
|
|
||||||
|
|||||||
+11
-11
@@ -5,7 +5,7 @@ import queue
|
|||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from somafm_tui.player import Player
|
from beefy_player.player import Player
|
||||||
|
|
||||||
FAKE_MPV = '''#!/usr/bin/env python3
|
FAKE_MPV = '''#!/usr/bin/env python3
|
||||||
import json, os, socket, sys, time
|
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:
|
def test_media_key_messages_become_player_events() -> None:
|
||||||
player = Player()
|
player = Player()
|
||||||
player._handle_mpv_message(
|
player._handle_mpv_message(
|
||||||
{"event": "client-message", "args": ["somafm", "next"]}
|
{"event": "client-message", "args": ["beefy", "next"]}
|
||||||
)
|
)
|
||||||
event = player.events.get_nowait()
|
event = player.events.get_nowait()
|
||||||
assert event.kind == "media"
|
assert event.kind == "media"
|
||||||
assert event.detail == "next"
|
assert event.detail == "next"
|
||||||
|
|
||||||
player._handle_mpv_message(
|
player._handle_mpv_message(
|
||||||
{"event": "client-message", "args": ["somafm", "toggle"]}
|
{"event": "client-message", "args": ["beefy", "toggle"]}
|
||||||
)
|
)
|
||||||
assert player.events.get_nowait().detail == "toggle"
|
assert player.events.get_nowait().detail == "toggle"
|
||||||
|
|
||||||
# Foreign namespaces, unknown commands and malformed payloads are ignored.
|
# 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": ["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", "args": None})
|
||||||
player._handle_mpv_message({"event": "client-message"})
|
player._handle_mpv_message({"event": "client-message"})
|
||||||
assert player.events.empty()
|
assert player.events.empty()
|
||||||
|
|
||||||
|
|
||||||
def test_platform_options_never_leak_to_linux(tmp_path, monkeypatch) -> None:
|
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)
|
monkeypatch.setattr(player_module, "RUNTIME_DIR", tmp_path)
|
||||||
assert Player()._platform_options("linux") == []
|
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")
|
assert conf.endswith("mpv-input.conf")
|
||||||
bindings = (tmp_path / "mpv-input.conf").read_text()
|
bindings = (tmp_path / "mpv-input.conf").read_text()
|
||||||
for line in (
|
for line in (
|
||||||
"PLAY script-message somafm toggle",
|
"PLAY script-message beefy toggle",
|
||||||
"PLAYONLY script-message somafm play",
|
"PLAYONLY script-message beefy play",
|
||||||
"PAUSEONLY script-message somafm pause",
|
"PAUSEONLY script-message beefy pause",
|
||||||
"STOP script-message somafm stop",
|
"STOP script-message beefy stop",
|
||||||
"NEXT script-message somafm next",
|
"NEXT script-message beefy next",
|
||||||
"PREV script-message somafm prev",
|
"PREV script-message beefy prev",
|
||||||
):
|
):
|
||||||
assert line in bindings
|
assert line in bindings
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from somafm_tui import spectrum as spectrum_module
|
from beefy_player import spectrum as spectrum_module
|
||||||
from somafm_tui.spectrum import Frame, SpectrumEngine
|
from beefy_player.spectrum import Frame, SpectrumEngine
|
||||||
|
|
||||||
PCM_SCRIPT = """#!/usr/bin/env python3
|
PCM_SCRIPT = """#!/usr/bin/env python3
|
||||||
import math, struct, sys, time
|
import math, struct, sys, time
|
||||||
@@ -42,7 +42,7 @@ def tap_pcm(
|
|||||||
|
|
||||||
|
|
||||||
class FakeTap:
|
class FakeTap:
|
||||||
"""Stand-in for ``somafm_tui.macos_tap.SystemAudioTap``."""
|
"""Stand-in for ``beefy_player.macos_tap.SystemAudioTap``."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import math
|
import math
|
||||||
|
|
||||||
from somafm_tui import config
|
from beefy_player import config
|
||||||
from somafm_tui.spectrum import Frame, SpectrumEngine
|
from beefy_player.spectrum import Frame, SpectrumEngine
|
||||||
from somafm_tui.widgets.spectrum import (
|
from beefy_player.widgets.spectrum import (
|
||||||
VISUALIZER_LABELS,
|
VISUALIZER_LABELS,
|
||||||
WATERFALL_HISTORY,
|
WATERFALL_HISTORY,
|
||||||
SpectrumPanel,
|
SpectrumPanel,
|
||||||
|
|||||||
+9
-9
@@ -5,15 +5,15 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from somafm_tui.app import SomaFMApp
|
from beefy_player.app import BeefyPlayerApp
|
||||||
from somafm_tui.widgets.header import HeaderBar
|
from beefy_player.widgets.header import HeaderBar
|
||||||
from somafm_tui.widgets.search import SearchInput
|
from beefy_player.widgets.search import SearchInput
|
||||||
from somafm_tui.widgets.spectrum import SpectrumPanel
|
from beefy_player.widgets.spectrum import SpectrumPanel
|
||||||
from somafm_tui.widgets.stage import StagePanel
|
from beefy_player.widgets.stage import StagePanel
|
||||||
from somafm_tui.widgets.station_list import StationList
|
from beefy_player.widgets.station_list import StationList
|
||||||
from somafm_tui.widgets.status import StatusBar
|
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:
|
def test_no_rgb_colors_in_source() -> None:
|
||||||
@@ -29,7 +29,7 @@ def test_no_rgb_colors_in_source() -> None:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_backgrounds_use_terminal_default_and_borders_are_ansi() -> None:
|
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:
|
async with app.run_test(size=(100, 30)) as pilot:
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from somafm_tui.widgets import common
|
from beefy_player.widgets import common
|
||||||
|
|
||||||
BERLIN = Path("/usr/share/zoneinfo/Europe/Berlin")
|
BERLIN = Path("/usr/share/zoneinfo/Europe/Berlin")
|
||||||
UTC_FILE = Path("/usr/share/zoneinfo/UTC")
|
UTC_FILE = Path("/usr/share/zoneinfo/UTC")
|
||||||
|
|||||||
Reference in new issue
Block a user