Compare commits

...
10 Commits
Author SHA1 Message Date
beefcarver 58e9202e25 test(providers): cover the registry and the app's catalogue independence
Registry lookup and its fallback for a stale slug, SomaFM branding and guessed mirrors, provider round-trip in the config, legacy-config migration and BEEFY_*/SOMAFM_* precedence - plus an end-to-end boot against a fake second provider, which is what actually proves the UI no longer knows about SomaFM.
2026-11-03 16:10:39 -05:00
beefcarver c4cc91ee1a feat(providers): add a pluggable station-catalogue seam
The app now holds one RadioProvider and asks it for stations, song history and stream URLs instead of importing SomaFM's HTTP client directly.

- models.py: provider-neutral Song/Station plus the shared parsers every catalogue needs (Station.provider/.uid keep ids unique once there is more than one).
- providers/base.py: the RadioProvider interface and the toolkit it needs in practice - httpx client factory, .pls/.m3u resolution with an 8h disk cache, and a default stream_candidates() strategy.
- providers/somafm/: everything SomaFM-specific (channels.json, songs/<id>.json, PLS layout, guessed icecast mirrors) behind SomaFMProvider.
- providers/__init__.py: registry, get_provider() and --provider, remembered in the config file.

Widgets render branding, splash copy and the help credits from the active provider, so a second catalogue needs no UI changes; with SomaFM selected the screen is byte-identical to before.
2026-11-03 16:10:30 -05:00
beefcarver e30ca09627 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.
2026-11-03 16:10:25 -05:00
beefcarver 4bed90b489 docs(visualizer): document the six visualiser modes 2026-11-01 09:58:59 -05:00
beefcarver a42678ef30 test(visualizer): cover the new modes and the full v cycle 2026-11-01 09:58:57 -05:00
beefcarver 6d188c00bc feat(visualizer): add waterfall, radial and braille spectrum modes 2026-11-01 09:58:53 -05:00
beefcarver b9d3d7e809 test(spectrum): cover monitor recovery across tuning 2026-11-01 09:45:47 -05:00
beefcarver ca45303386 fix(spectrum): reset analyser on station change and retry SYNC 2026-11-01 09:45:43 -05:00
beefcarver d2b4f1bf64 fix(ui): keep Bandcamp link across history refreshes 2026-11-01 08:58:46 -05:00
beefcarver 3ae4dc3dab feat(bandcamp): simulate typed autocomplete searches 2026-11-01 08:58:43 -05:00
55 changed files with 2468 additions and 1031 deletions

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 DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004 Version 2, December 2004
+144 -63
View File
@@ -1,25 +1,33 @@
# 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 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
- **Live spectrum analyser** — 72 log-spaced bands with peak caps, a moving - **Live spectrum analyser** — 72 log-spaced bands with peak caps, a moving
gradient taken from your terminal's ANSI palette and a symmetric mirrored gradient taken from your terminal's ANSI palette and a symmetric mirrored
spectrum; or a full-height oscilloscope. Three modes (`v`). spectrum. Six modes (`v`): mirrored bars, plain bars, oscilloscope,
scrolling waterfall spectrogram, a radial spectrum wrapped around the
centre, and a high-resolution braille oscilloscope (2×4 dots per cell).
- **Synced spectrum, not a guess** — by default the analyser records your - **Synced spectrum, not a guess** — by default the analyser records your
actual audio output, so the bars follow what you hear (`SYNC`): the actual audio output, so the bars follow what you hear (`SYNC`): the
PulseAudio/PipeWire monitor via `parec` on Linux, and a native Core Audio PulseAudio/PipeWire monitor via `parec` on Linux, and a native Core Audio
@@ -31,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
@@ -56,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`.
@@ -125,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
@@ -161,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
@@ -191,8 +245,8 @@ The UI wants roughly **84×24**; it also works smaller, just less pretty.
| `f` | favourite (favourites sort to the top) | | `f` | favourite (favourites sort to the top) |
| `/` | filter by title, genre, description | | `/` | filter by title, genre, description |
| `esc` | clear filter / close search | | `esc` | clear filter / close search |
| `v` | visualiser: mirrored bars → bars → wave | | `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 |
@@ -200,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
@@ -208,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
@@ -227,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
@@ -291,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
@@ -338,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.
+3
View File
@@ -0,0 +1,3 @@
"""Beefy Player — a flashy terminal player for internet radio."""
__version__ = "0.1.0"
@@ -6,16 +6,23 @@ import argparse
import asyncio import asyncio
from collections.abc import Sequence from collections.abc import Sequence
from . import __version__, api from . import __version__
from .fallback import fallback_channels from .providers import available_providers, get_provider
from .providers.base import make_client
from .spectrum import SOURCE_MODES from .spectrum import SOURCE_MODES
def _build_parser() -> argparse.ArgumentParser: def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="somafm-tui", prog="beefy-player",
description="A flashy terminal radio for somafm.com — live spectrum, " description="Beefy Player — a flashy terminal player for internet "
"song history and desktop media integration.", "radio: live spectrum, song history and desktop media integration.",
)
parser.add_argument(
"--provider",
choices=available_providers(),
metavar="ID",
help="station catalogue to use (default: the one in the config file)",
) )
parser.add_argument("--station", metavar="ID", help="station id to select") parser.add_argument("--station", metavar="ID", help="station id to select")
parser.add_argument( parser.add_argument(
@@ -72,13 +79,15 @@ def _build_parser() -> argparse.ArgumentParser:
return parser return parser
def _list_stations(offline: bool) -> int: def _list_stations(offline: bool, provider_id: str | None = None) -> int:
stations = api.load_cached_channels() provider = get_provider(provider_id)
stations = provider.load_cached_stations()
if not stations and not offline: if not stations and not offline:
async def load(): async def load():
client = api.make_client() client = make_client(provider.user_agent)
try: try:
return await api.fetch_channels(client) return await provider.fetch_stations(client)
finally: finally:
await client.aclose() await client.aclose()
@@ -87,7 +96,8 @@ def _list_stations(offline: bool) -> int:
except Exception: except Exception:
stations = [] stations = []
if not stations: if not stations:
stations = api.parse_channels({"channels": fallback_channels()}) stations = provider.fallback_stations()
print(f"# {provider.name} ({provider.id}) — {provider.station_source}")
for station in sorted(stations, key=lambda item: item.title.casefold()): for station in sorted(stations, key=lambda item: item.title.casefold()):
print(f"{station.id:<20} {station.title}") print(f"{station.id:<20} {station.title}")
return 0 return 0
@@ -103,13 +113,14 @@ def main(argv: Sequence[str] | None = None) -> int:
return 0 return 0
if args.list: if args.list:
return _list_stations(args.offline) return _list_stations(args.offline, args.provider)
# Import the app lazily so --list/--version do not probe the terminal for # Import the app lazily so --list/--version do not probe the terminal for
# image protocols. # image protocols.
from .app import SomaFMApp from .app import BeefyPlayerApp
app = SomaFMApp( app = BeefyPlayerApp(
provider=args.provider,
autoplay=args.autoplay, autoplay=args.autoplay,
station_id=args.station, station_id=args.station,
volume=args.volume, volume=args.volume,
+58 -29
View File
@@ -1,4 +1,10 @@
"""The Textual application: layout, key bindings and orchestration.""" """The Textual application: layout, key bindings and orchestration.
The app is deliberately catalogue-agnostic: it holds one
:class:`~beefy_player.providers.base.RadioProvider` and asks it for stations,
song history and stream URLs. SomaFM is one such provider; see
:mod:`beefy_player.providers`.
"""
from __future__ import annotations from __future__ import annotations
@@ -17,11 +23,12 @@ from textual.css.query import NoMatches
from textual.theme import Theme from textual.theme import Theme
from textual.widgets import Input from textual.widgets import Input
from . import api, artwork, bandcamp, config from . import artwork, bandcamp, config
from .api import Station
from .fallback import fallback_channels
from .mediasession import create_media_session, media_actions_queue from .mediasession import create_media_session, media_actions_queue
from .models import Station, parse_last_playing
from .player import Player from .player import Player
from .providers import get_provider
from .providers.base import is_direct_stream, make_client
from .spectrum import SpectrumEngine from .spectrum import SpectrumEngine
from .widgets.header import HeaderBar from .widgets.header import HeaderBar
from .widgets.help import HelpScreen from .widgets.help import HelpScreen
@@ -56,11 +63,11 @@ TERMINAL_THEME = Theme(
) )
class SomaFMApp(App[None]): class BeefyPlayerApp(App[None]):
CSS_PATH = "theme.tcss" CSS_PATH = "theme.tcss"
ENABLE_COMMAND_PALETTE = False ENABLE_COMMAND_PALETTE = False
TITLE = "SomaFM" TITLE = "Beefy Player"
SUB_TITLE = "terminal radio" SUB_TITLE = "terminal internet radio"
BINDINGS: ClassVar[list[Binding]] = [ BINDINGS: ClassVar[list[Binding]] = [
Binding("up,k", "cursor_up", "up", show=False), Binding("up,k", "cursor_up", "up", show=False),
@@ -89,6 +96,7 @@ class SomaFMApp(App[None]):
def __init__( def __init__(
self, self,
*, *,
provider: str | None = None,
autoplay: bool | None = None, autoplay: bool | None = None,
station_id: str | None = None, station_id: str | None = None,
volume: int | None = None, volume: int | None = None,
@@ -104,6 +112,8 @@ class SomaFMApp(App[None]):
self.register_theme(TERMINAL_THEME) self.register_theme(TERMINAL_THEME)
self.theme = TERMINAL_THEME.name self.theme = TERMINAL_THEME.name
self.config = config.Config.load() self.config = config.Config.load()
#: The station catalogue in use (see :mod:`beefy_player.providers`).
self.provider = get_provider(provider or self.config.provider)
# One-off command line overrides are deliberately *not* written back # One-off command line overrides are deliberately *not* written back
# into the config file (only an explicit --volume is remembered). # into the config file (only an explicit --volume is remembered).
self.autoplay = self.config.autoplay if autoplay is None else autoplay self.autoplay = self.config.autoplay if autoplay is None else autoplay
@@ -139,6 +149,7 @@ class SomaFMApp(App[None]):
self.artwork_bytes: bytes | None = None self.artwork_bytes: bytes | None = None
self._art_track_key = "" self._art_track_key = ""
self._bandcamp_key = "" self._bandcamp_key = ""
self._bandcamp_urls: dict[str, str] = {}
self._http: httpx.AsyncClient | None = None self._http: httpx.AsyncClient | None = None
self._closed = False self._closed = False
self._tick_timer = None self._tick_timer = None
@@ -176,16 +187,16 @@ class SomaFMApp(App[None]):
self.spectrum.start() self.spectrum.start()
self.media.start() self.media.start()
self._http = api.make_client() self._http = make_client(self.provider.user_agent)
stations = api.load_cached_channels() stations = self.provider.load_cached_stations()
if not stations: if not stations:
stations = api.parse_channels({"channels": fallback_channels()}) stations = self.provider.fallback_stations()
self._set_stations(stations) self._set_stations(stations)
self._select_initial_station() self._select_initial_station()
if self.current is not None: if self.current is not None:
cached = api.load_cached_songs(self.current.id) cached = self.provider.load_cached_history(self.current.id)
if cached: if cached:
self.current.history = cached self.current.history = cached
self.current.now_playing = cached[0] self.current.now_playing = cached[0]
@@ -194,7 +205,7 @@ class SomaFMApp(App[None]):
self._tick_timer = self.set_interval(0.05, self._tick) self._tick_timer = self.set_interval(0.05, self._tick)
if not self.offline: if not self.offline:
self._refresh_channels() self._refresh_stations()
if self.current is not None: if self.current is not None:
self._fetch_history(self.current.id) self._fetch_history(self.current.id)
@@ -237,7 +248,7 @@ class SomaFMApp(App[None]):
station.favorite = previous.favorite station.favorite = previous.favorite
station.favorite = self.config.is_favorite(station.id) station.favorite = self.config.is_favorite(station.id)
if station.now_playing is None and station.last_playing: if station.now_playing is None and station.last_playing:
station.now_playing = api.parse_last_playing(station.last_playing) station.now_playing = parse_last_playing(station.last_playing)
self.stations = stations self.stations = stations
wanted = self.current.id if self.current is not None else self.start_station_id wanted = self.current.id if self.current is not None else self.start_station_id
@@ -323,6 +334,9 @@ class SomaFMApp(App[None]):
self._set_notice("audio disabled (--no-audio)", 5) self._set_notice("audio disabled (--no-audio)", 5)
return return
# A tune gets a clean analyser: forget any previous capture failure and
# clear the old station's bars before the new stream starts.
self.spectrum.reset()
self._resolve_and_play(station.id) self._resolve_and_play(station.id)
self._fetch_history(station.id) self._fetch_history(station.id)
@@ -333,10 +347,10 @@ class SomaFMApp(App[None]):
return return
if self.offline or self._http is None: if self.offline or self._http is None:
urls = station.synthetic_mirrors() urls = self.provider.fallback_urls(station)
else: else:
try: try:
urls = await api.stream_candidates(self._http, station) urls = await self.provider.stream_candidates(self._http, station)
except Exception as exc: except Exception as exc:
self.playing_state = "error" self.playing_state = "error"
self._set_notice(f"could not resolve stream: {exc}", 8) self._set_notice(f"could not resolve stream: {exc}", 8)
@@ -353,7 +367,7 @@ class SomaFMApp(App[None]):
self.query_one(StagePanel).refresh_stage() self.query_one(StagePanel).refresh_stage()
return return
direct = next((url for url in urls if api.is_direct_stream(url)), None) direct = next((url for url in urls if is_direct_stream(url)), None)
self._stream_url = direct or urls[0] self._stream_url = direct or urls[0]
if direct is not None: if direct is not None:
self.spectrum.set_source(direct) self.spectrum.set_source(direct)
@@ -367,15 +381,19 @@ class SomaFMApp(App[None]):
if station is None or self._http is None: if station is None or self._http is None:
return return
try: try:
songs = await api.fetch_history(self._http, station_id) songs = await self.provider.fetch_history(self._http, station_id)
except Exception: except Exception:
songs = api.load_cached_songs(station_id) songs = self.provider.load_cached_history(station_id)
if not songs: if not songs:
return return
previous = station.now_playing previous = station.now_playing
station.history = songs station.history = songs
station.now_playing = songs[0] station.now_playing = songs[0]
if previous is not None and previous.key() == songs[0].key():
# History refreshes replace the Song object even when the track has
# not changed. Keep the asynchronously discovered Bandcamp link.
songs[0].bandcamp_url = previous.bandcamp_url
changed = previous is None or previous.key() != songs[0].key() changed = previous is None or previous.key() != songs[0].key()
if self.current is not None and self.current.id == station_id: if self.current is not None and self.current.id == station_id:
if changed: if changed:
@@ -400,9 +418,9 @@ class SomaFMApp(App[None]):
return return
song = station.now_playing song = station.now_playing
self.title = ( self.title = (
f"SomaFM — {song.display_artist} · {song.display_title}" f"{self.provider.name} — {song.display_artist} · {song.display_title}"
if song is not None if song is not None
else "SomaFM" else self.provider.name
) )
if song is not None: if song is not None:
self.media.set_track( self.media.set_track(
@@ -418,7 +436,7 @@ class SomaFMApp(App[None]):
self.media.set_track( self.media.set_track(
station=station.title, station=station.title,
title=station.last_playing or station.title, title=station.last_playing or station.title,
artist=station.dj or "SomaFM", artist=station.dj or self.provider.name,
album=station.description, album=station.description,
art_url=station.image, art_url=station.image,
stream_url=self._stream_url, stream_url=self._stream_url,
@@ -432,13 +450,24 @@ class SomaFMApp(App[None]):
def _update_bandcamp(self) -> None: def _update_bandcamp(self) -> None:
"""Find a Bandcamp track link once per now-playing track.""" """Find a Bandcamp track link once per now-playing track."""
station = self.current station = self.current
if station is None or self.offline or self._http is None: if station is None:
return return
song = station.now_playing song = station.now_playing
if song is None or not (song.artist.strip() or song.title.strip()): if song is None or not (song.artist.strip() or song.title.strip()):
return return
track_key = f"{station.id}|{song.key()}" track_key = f"{station.id}|{song.key()}"
cached_url = self._bandcamp_urls.get(track_key)
if cached_url:
self._bandcamp_key = track_key
if song.bandcamp_url != cached_url:
song.bandcamp_url = cached_url
with contextlib.suppress(NoMatches):
self.query_one(StagePanel).refresh_stage()
return
if self.offline or self._http is None:
return
if track_key == self._bandcamp_key: if track_key == self._bandcamp_key:
return return
self._bandcamp_key = track_key self._bandcamp_key = track_key
@@ -461,6 +490,7 @@ class SomaFMApp(App[None]):
if not url: if not url:
return return
self._bandcamp_urls[f"{station_id}|{song_key}"] = url
station = next((s for s in self.stations if s.id == station_id), None) station = next((s for s in self.stations if s.id == station_id), None)
if station is None or self.current is None or self.current.id != station_id: if station is None or self.current is None or self.current.id != station_id:
return return
@@ -667,17 +697,15 @@ class SomaFMApp(App[None]):
# workers # workers
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@work(exclusive=True, group="channels", exit_on_error=False) @work(exclusive=True, group="stations", exit_on_error=False)
async def _refresh_channels(self) -> None: async def _refresh_stations(self) -> None:
if self.offline or self._http is None: if self.offline or self._http is None:
return return
try: try:
stations = await api.fetch_channels(self._http) stations = await self.provider.fetch_stations(self._http)
except Exception: except Exception:
if not self.stations: if not self.stations:
self._set_stations( self._set_stations(self.provider.fallback_stations())
api.parse_channels({"channels": fallback_channels()})
)
self._set_notice("offline — using the built-in station list", 8) self._set_notice("offline — using the built-in station list", 8)
else: else:
self._set_notice("station refresh failed — using the cached list", 6) self._set_notice("station refresh failed — using the cached list", 6)
@@ -822,12 +850,13 @@ class SomaFMApp(App[None]):
def action_refresh(self) -> None: def action_refresh(self) -> None:
self._last_history_fetch = 0.0 self._last_history_fetch = 0.0
self._bandcamp_key = "" self._bandcamp_key = ""
self._bandcamp_urls.clear()
self._set_notice("refreshing…", 2) self._set_notice("refreshing…", 2)
if self.offline: if self.offline:
self._set_notice("offline mode — nothing to refresh", 3) self._set_notice("offline mode — nothing to refresh", 3)
return return
self.artwork_url = "" self.artwork_url = ""
self._refresh_channels() self._refresh_stations()
if self.current is not None: if self.current is not None:
self._update_artwork() self._update_artwork()
self._fetch_history(self.current.id) self._fetch_history(self.current.id)
@@ -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),
} }
@@ -1,8 +1,11 @@
"""Look up the now-playing track on Bandcamp. """Look up the now-playing track on Bandcamp.
Bandcamp's own search box talks to an unauthenticated JSON endpoint Bandcamp's own search box talks to an unauthenticated JSON endpoint
(``/api/bcsearch_public_api/1/autocomplete_elastic``); this uses that instead (``/api/bcsearch_public_api/1/autocomplete_elastic``). The endpoint is meant
of scraping the search page. It first searches for the artist and track, to be driven by a person typing, so this module mimics that: the query is
normalised, then sent as a series of growing chunks (three to five characters
at a time). After every chunk the returned results are matched fuzzily
against the artist and track. It first searches for the artist and track,
then falls back to the artist and album and reads the track link from that then falls back to the artist and album and reads the track link from that
album page. Anything unexpected — a bot challenge, a layout change, an empty album page. Anything unexpected — a bot challenge, a layout change, an empty
result set — is treated as "no link" rather than as an error. result set — is treated as "no link" rather than as an error.
@@ -10,9 +13,9 @@ result set — is treated as "no link" rather than as an error.
from __future__ import annotations from __future__ import annotations
import difflib
import html import html
import json import json
import re
import unicodedata import unicodedata
from dataclasses import dataclass from dataclasses import dataclass
from html.parser import HTMLParser from html.parser import HTMLParser
@@ -51,6 +54,27 @@ def _clean_text(value: str) -> str:
return " ".join(html.unescape(value or "").split()) return " ".join(html.unescape(value or "").split())
def _clean_query_text(value: str) -> str:
"""Normalise text for the autocomplete endpoint.
Em dashes, brackets, parentheses and every other punctuation mark except
comma, period, exclamation mark and question mark are removed. The
result is lower-cased and whitespace is collapsed. Em dashes become a
space so that ``Artist—Track`` does not turn into one word.
"""
decomposed = unicodedata.normalize("NFKD", html.unescape(value or ""))
text = "".join(
character for character in decomposed if not unicodedata.combining(character)
).casefold()
cleaned: list[str] = []
for character in text:
if character.isalnum() or character in ",.!?":
cleaned.append(character)
elif character.isspace() or character in "—–―":
cleaned.append(" ")
return " ".join("".join(cleaned).split())
def _clean_url(value: str, base: str = "https://bandcamp.com/") -> str: def _clean_url(value: str, base: str = "https://bandcamp.com/") -> str:
text = html.unescape(value or "").strip() text = html.unescape(value or "").strip()
if not text: if not text:
@@ -226,15 +250,43 @@ def parse_album_tracks(markup: str, album_url: str) -> list[SearchResult]:
def build_query(*values: str) -> str: def build_query(*values: str) -> str:
terms: list[str] = [] """Join values into the lower-case query string sent while typing."""
seen: set[str] = set() terms = [_clean_query_text(value) for value in values]
for value in values: return " ".join(term for term in terms if term)
text = _clean_text(value)
folded = text.casefold()
if text and folded not in seen: MIN_TYPING_GROUP = 3
seen.add(folded) MAX_TYPING_GROUP = 5
terms.append(text)
return " ".join(terms)
def build_prefixes(query: str) -> list[str]:
"""Return the growing strings produced by "typing" *query*.
Each group is between :data:`MIN_TYPING_GROUP` and
:data:`MAX_TYPING_GROUP` characters long, and every prefix contains all
of the text typed so far. For example ``diskette park dreams`` produces
``dis``, ``disket``, ``diskette ``, ``diskette par``, …
"""
text = query.strip()
if not text:
return []
groups: list[str] = []
index = 0
while index < len(text):
remaining = len(text) - index
if remaining <= MAX_TYPING_GROUP:
groups.append(text[index:])
break
groups.append(text[index : index + MIN_TYPING_GROUP])
index += MIN_TYPING_GROUP
prefixes: list[str] = []
typed = ""
for group in groups:
typed += group
prefixes.append(typed)
return prefixes
def _normalise(value: str) -> str: def _normalise(value: str) -> str:
@@ -242,8 +294,10 @@ def _normalise(value: str) -> str:
ascii_text = "".join( ascii_text = "".join(
character for character in decomposed if not unicodedata.combining(character) character for character in decomposed if not unicodedata.combining(character)
) )
ascii_text = ascii_text.replace("&", " and ") ascii_text = ascii_text.replace("&", " and ").casefold()
return " ".join(re.findall(r"[a-z0-9]+", ascii_text.casefold())) return " ".join(
"".join(character if character.isalnum() else " " for character in ascii_text).split()
)
def _tokens(value: str) -> set[str]: def _tokens(value: str) -> set[str]:
@@ -251,16 +305,27 @@ def _tokens(value: str) -> set[str]:
def _matches(expected: str, actual: str) -> bool: def _matches(expected: str, actual: str) -> bool:
expected_tokens = _tokens(expected) """Case- and whitespace-insensitive fuzzy comparison of two labels."""
if not expected_tokens: expected_key = _normalise(expected)
if not expected_key:
return True return True
actual_tokens = _tokens(actual) actual_key = _normalise(actual)
if not actual_tokens: if not actual_key:
return False return False
if expected_key == actual_key:
return True
expected_tokens = set(expected_key.split())
actual_tokens = set(actual_key.split())
if expected_tokens <= actual_tokens or actual_tokens <= expected_tokens: if expected_tokens <= actual_tokens or actual_tokens <= expected_tokens:
return True return True
overlap = len(expected_tokens & actual_tokens) overlap = len(expected_tokens & actual_tokens)
return overlap / len(expected_tokens) >= 0.6 if overlap / len(expected_tokens) >= 0.6:
return True
# Tolerate small spelling differences and typos once the word-level
# comparison has failed.
return difflib.SequenceMatcher(None, expected_key, actual_key).ratio() >= 0.85
def pick_track( def pick_track(
@@ -336,13 +401,28 @@ async def _album_tracks(
return parse_album_tracks(response.text, album_url) return parse_album_tracks(response.text, album_url)
async def _search_while_typing(
client: httpx.AsyncClient,
query: str,
search_filter: str,
pick,
) -> str | None:
"""Send growing prefixes of *query* and return the first selected URL."""
for prefix in build_prefixes(query):
url = pick(await _search(client, prefix, search_filter))
if url:
return url
return None
async def find_track( async def find_track(
client: httpx.AsyncClient, artist: str, album: str, title: str client: httpx.AsyncClient, artist: str, album: str, title: str
) -> str | None: ) -> str | None:
"""Find the now-playing track on Bandcamp. """Find the now-playing track on Bandcamp.
Search for the artist + track first. If that finds nothing, search for Type the artist + track query into the autocomplete endpoint first. If
the artist + album, open the album page and look for the track there. that finds nothing, do the same for the artist + album, open the album
page and look for the track there.
""" """
artist = _clean_text(artist) artist = _clean_text(artist)
album = _clean_text(album) album = _clean_text(album)
@@ -351,8 +431,11 @@ async def find_track(
return None return None
track_query = build_query(artist, title) track_query = build_query(artist, title)
url = pick_track( url = await _search_while_typing(
await _search(client, track_query, "t"), artist, "", title client,
track_query,
"t",
lambda results: pick_track(results, artist, "", title),
) )
if url: if url:
return url return url
@@ -361,7 +444,12 @@ async def find_track(
return None return None
album_query = build_query(artist, album) album_query = build_query(artist, album)
album_url = pick_album(await _search(client, album_query, "a"), artist, album) album_url = await _search_while_typing(
client,
album_query,
"a",
lambda results: pick_album(results, artist, album),
)
if not album_url: if not album_url:
return None return None
@@ -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,8 +62,12 @@ 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"
VISUALIZERS = ("both", "bars", "wave") #: 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")
def ensure_dirs() -> None: def ensure_dirs() -> None:
@@ -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.
""" """
+171
View File
@@ -0,0 +1,171 @@
"""Provider-neutral data types.
Every radio catalogue — SomaFM today, anything else later — is reduced to
:class:`Station` and :class:`Song` before the UI sees it, so the widgets and
the app never have to know which catalogue a station came from. The parsers
at the bottom are the small, boring helpers (numbers, dates, "Artist - Title"
strings) that every provider ends up needing.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
_QUALITY_RANK = {"highest": 0, "high": 1, "low": 2}
_FORMAT_RANK = {"mp3": 0, "aac": 1, "aacp": 2}
_FORMAT_LABEL = {"aacp": "aac+", "aac": "aac", "mp3": "mp3"}
@dataclass(slots=True)
class Song:
title: str = ""
artist: str = ""
album: str = ""
art_url: str = ""
bandcamp_url: str = ""
played_at: datetime | None = None
@property
def display_title(self) -> str:
return self.title.strip() or "Unknown track"
@property
def display_artist(self) -> str:
return self.artist.strip() or "Unknown artist"
def key(self) -> str:
return f"{self.artist}\x1f{self.title}\x1f{self.album}"
@dataclass(slots=True)
class Station:
id: str
title: str
description: str = ""
genre: str = ""
dj: str = ""
image: str = ""
listeners: int = 0
last_playing: str = ""
playlists: list[dict[str, str]] = field(default_factory=list)
now_playing: Song | None = None
history: list[Song] = field(default_factory=list)
favorite: bool = False
#: Id of the provider that produced this station (see
#: :mod:`beefy_player.providers`). Station ids are only unique within a
#: catalogue, so anything persisted across providers should use
#: :attr:`uid` instead.
provider: str = ""
@property
def uid(self) -> str:
"""Provider-qualified id, stable once more than one catalogue exists."""
return f"{self.provider}:{self.id}" if self.provider else self.id
@property
def genre_label(self) -> str:
return self.genre.replace("|", " · ") or "eclectic"
def sorted_playlists(self) -> list[dict[str, str]]:
return sorted(
self.playlists,
key=lambda pl: (
_QUALITY_RANK.get(pl.get("quality", ""), 3),
_FORMAT_RANK.get(pl.get("format", ""), 3),
),
)
@property
def format_label(self) -> str:
playlists = self.sorted_playlists()
if not playlists:
return "stream"
first = playlists[0]
fmt = _FORMAT_LABEL.get(
first.get("format", ""), first.get("format") or "stream"
)
return f"{fmt} · {guess_bitrate(first.get('url', ''))}k"
# --------------------------------------------------------------------------- #
# small, provider-agnostic parsers
# --------------------------------------------------------------------------- #
def guess_bitrate(url: str) -> int:
match = re.search(
r"(256|192|130|128|96|64|48|32)(?=\.pls|\.m3u|-mp3|-aac)", url
)
if not match:
return 128
value = int(match.group(1))
return 128 if value == 130 else value
def to_int(value: Any, default: int = 0) -> int:
"""Forgiving ``int`` — catalogues like to send ``"1,873"`` as text."""
if isinstance(value, bool):
return default
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
if isinstance(value, str):
digits = re.sub(r"[^0-9]", "", value)
if digits:
try:
return int(digits)
except ValueError:
return default
return default
def parse_date(value: Any) -> datetime | None:
"""Unix timestamp, ISO 8601 or one of the usual ``%Y-%m-%d`` shapes."""
if value is None or value == "":
return None
if isinstance(value, (int, float)):
try:
return datetime.fromtimestamp(float(value), tz=timezone.utc)
except (OverflowError, OSError, ValueError):
return None
if isinstance(value, str):
text = value.strip()
if text.isdigit():
try:
return datetime.fromtimestamp(int(text), tz=timezone.utc)
except (OverflowError, OSError, ValueError):
return None
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
parsed = None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y/%m/%d %H:%M:%S"):
try:
parsed = datetime.strptime(text, fmt)
break
except ValueError:
continue
if parsed is not None:
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
return None
_SEPARATORS = (" - ", " – ", " — ", " -- ")
def parse_last_playing(text: str | None) -> Song | None:
"""Split the ``"Artist - Title"`` blurb most catalogues send."""
if not isinstance(text, str):
return None
cleaned = text.strip()
if not cleaned:
return None
for separator in _SEPARATORS:
if separator in cleaned:
artist, title = cleaned.split(separator, 1)
return Song(title=title.strip(), artist=artist.strip())
return Song(title=cleaned)
+11 -9
View File
@@ -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()
+13 -14
View File
@@ -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
+53
View File
@@ -0,0 +1,53 @@
"""The catalogue registry.
Beefy Player speaks to internet radio through :class:`RadioProvider`
implementations (see :mod:`beefy_player.providers.base`). Each provider is
registered here once; the app looks its provider up by slug, and ``--provider``
on the command line is validated against :func:`available_providers`.
Adding a catalogue is meant to be a small, contained change:
1. write the package (``beefy_player/providers/<slug>/``) with a
:class:`~beefy_player.providers.base.RadioProvider` subclass,
2. add one instance to :data:`PROVIDERS` below,
3. that is it — the UI, the player, the spectrum and the media session do not
care where the stations came from.
"""
from __future__ import annotations
from .base import RadioProvider
from .somafm import SomaFMProvider
__all__ = [
"DEFAULT_PROVIDER",
"PROVIDERS",
"RadioProvider",
"available_providers",
"get_provider",
]
#: Every catalogue the app can talk to, keyed by its slug.
PROVIDERS: dict[str, RadioProvider] = {
provider.id: provider for provider in (SomaFMProvider(),)
}
#: Provider used when nothing else is configured.
DEFAULT_PROVIDER = SomaFMProvider.id
def available_providers() -> tuple[str, ...]:
"""Registered provider slugs, for ``--provider`` and the config file."""
return tuple(PROVIDERS)
def get_provider(provider_id: str | None = None) -> RadioProvider:
"""Look a provider up by slug, falling back to the default one.
An unknown slug is a stale config file or a typo, not a reason to refuse to
start; the help screen and ``--list`` always name what is in use.
"""
key = (provider_id or "").strip().lower()
if key in PROVIDERS:
return PROVIDERS[key]
return PROVIDERS[DEFAULT_PROVIDER]
+249
View File
@@ -0,0 +1,249 @@
"""The provider seam: what a radio catalogue has to implement.
Beefy Player is an internet-radio player. Everything the UI needs from a
catalogue — the station list, the song history, the stream URLs — goes through
:class:`RadioProvider`, and the app only ever holds one of those. SomaFM
(:mod:`beefy_player.providers.somafm`) is the first implementation; adding
another station catalogue means writing another subclass and registering it in
:mod:`beefy_player.providers`, with no changes to the app or the widgets.
This module also carries the toolkit a provider needs in practice: an HTTP
client factory and playlist handling (``.pls``/``.m3u`` resolution with an
on-disk cache), which are the same for every catalogue.
"""
from __future__ import annotations
import asyncio
import re
import time
from abc import ABC, abstractmethod
from typing import Any
from urllib.parse import quote
import httpx
from .. import config
from ..models import Song, Station
__all__ = [
"PLAYLIST_TTL",
"RadioProvider",
"fetch_json",
"is_direct_stream",
"is_playlist",
"make_client",
"parse_pls",
"quote_station_id",
"reset_playlist_cache",
"resolve_playlist",
]
REQUEST_TIMEOUT = httpx.Timeout(15.0, connect=8.0)
PLAYLIST_TTL = 8 * 3600
_PLAYLIST_CACHE: dict[str, dict[str, Any]] | None = None
def make_client(user_agent: str = "") -> httpx.AsyncClient:
return httpx.AsyncClient(
headers={
"User-Agent": user_agent or config.USER_AGENT,
"Accept": "application/json, */*",
},
timeout=REQUEST_TIMEOUT,
follow_redirects=True,
)
def reset_playlist_cache() -> None:
"""Forget the in-memory playlist cache (the tests use this)."""
global _PLAYLIST_CACHE
_PLAYLIST_CACHE = None
# --------------------------------------------------------------------------- #
# the interface
# --------------------------------------------------------------------------- #
class RadioProvider(ABC):
"""A station catalogue plus its streams and song history.
Providers are stateless singletons registered in
:data:`beefy_player.providers.PROVIDERS`; the app keeps one for its whole
lifetime and never instantiates another.
"""
#: Stable slug, used in caches, config and ``--provider``.
id: str = ""
#: Display name, e.g. ``"SomaFM"``.
name: str = ""
#: Bare domain, for docs and the help screen.
homepage: str = ""
#: Brand shown above the station list before anything is tuned in.
brand: str = ""
#: One-liner under the brand.
tagline: str = ""
#: Where stations come from, e.g. ``"somafm.com/channels.json"``.
station_source: str = ""
#: Where the song history comes from, e.g. ``"songs/<station>.json"``.
history_source: str = ""
#: ``User-Agent`` for this catalogue's requests (SomaFM likes to know).
user_agent: str = ""
# -- catalogue ------------------------------------------------------- #
@abstractmethod
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
"""Fetch the live station list and refresh the on-disk cache."""
@abstractmethod
def load_cached_stations(self) -> list[Station]:
"""The last good station list from disk, or ``[]``."""
@abstractmethod
def fallback_stations(self) -> list[Station]:
"""A bundled list, so a fresh offline start still works."""
# -- metadata -------------------------------------------------------- #
@abstractmethod
async def fetch_history(
self, client: httpx.AsyncClient, station_id: str
) -> list[Song]:
"""Recently played songs, newest first (falls back to the cache)."""
@abstractmethod
def load_cached_history(self, station_id: str) -> list[Song]:
"""The last good song history from disk, or ``[]``."""
# -- streams --------------------------------------------------------- #
def fallback_urls(self, station: Station) -> list[str]:
"""Guessed stream URLs, used when the catalogue is unreachable."""
return []
async def stream_candidates(
self, client: httpx.AsyncClient, station: Station
) -> list[str]:
"""Direct stream URLs to try, best first.
Resolves the station's own playlists (a couple of ``.pls`` files at
most) and appends :meth:`fallback_urls` in case none of them answer.
"""
urls: list[str] = []
playlists = station.sorted_playlists()
for entry in playlists[:3]:
url = entry.get("url", "")
resolved = await resolve_playlist(client, url)
for candidate in resolved:
if candidate not in urls:
urls.append(candidate)
if not resolved and url and url not in urls:
urls.append(url)
for entry in playlists[3:]:
url = entry.get("url", "")
if url and url not in urls:
urls.append(url)
for url in self.fallback_urls(station):
if url not in urls:
urls.append(url)
return urls[:10]
# --------------------------------------------------------------------------- #
# playlists
# --------------------------------------------------------------------------- #
def is_playlist(url: str) -> bool:
path = url.split("?", 1)[0].split("#", 1)[0].lower()
return path.endswith((".pls", ".m3u", ".m3u8"))
def is_direct_stream(url: str) -> bool:
return url.startswith(("http://", "https://")) and not is_playlist(url)
def parse_pls(text: str) -> list[str]:
urls: list[str] = []
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
match = re.match(r"(?i)^file\d+\s*=\s*(\S+)$", line)
candidate = match.group(1) if match else line
if candidate.startswith(("http://", "https://")) and candidate not in urls:
urls.append(candidate)
return urls
def _playlist_cache() -> dict[str, dict[str, Any]]:
global _PLAYLIST_CACHE
if _PLAYLIST_CACHE is None:
raw = config.read_json(config.PLAYLIST_CACHE)
_PLAYLIST_CACHE = raw if isinstance(raw, dict) else {}
return _PLAYLIST_CACHE
async def resolve_playlist(client: httpx.AsyncClient, url: str) -> list[str]:
"""Turn a ``.pls`` URL into direct icecast URLs (cached for a few hours)."""
if not url:
return []
if not is_playlist(url):
return [url]
cache = _playlist_cache()
now = time.time()
entry = cache.get(url)
if isinstance(entry, dict):
fetched = entry.get("ts")
urls = entry.get("urls")
if (
isinstance(fetched, (int, float))
and now - fetched < PLAYLIST_TTL
and isinstance(urls, list)
):
cached_urls = [item for item in urls if isinstance(item, str)]
if cached_urls:
return cached_urls
try:
response = await client.get(url, headers={"Accept": "*/*"})
response.raise_for_status()
text = response.text
except httpx.HTTPError:
return []
urls = parse_pls(text)
if urls:
cache[url] = {"ts": now, "urls": urls}
config.write_json(config.PLAYLIST_CACHE, cache)
return urls
def quote_station_id(station_id: str) -> str:
"""Percent-encode a station id for use in a provider URL."""
return quote(station_id, safe="")
async def fetch_json(
client: httpx.AsyncClient, url: str, *, attempts: int = 2, pause: float = 0.6
) -> Any:
"""``GET url`` and decode JSON, retrying once: flaky mirrors are normal."""
last_error: Exception | None = None
for attempt in range(attempts):
try:
response = await client.get(url)
response.raise_for_status()
return response.json()
except (httpx.HTTPError, ValueError) as exc: # includes JSON errors
last_error = exc
if attempt + 1 < attempts:
await asyncio.sleep(pause)
raise RuntimeError(f"could not fetch {url}: {last_error}")
+55
View File
@@ -0,0 +1,55 @@
"""SomaFM: the station catalogue Beefy Player was born with.
Everything SomaFM-specific lives in this package — the ``channels.json``
schema, the ``songs/<station>.json`` history, the ``.pls`` layout and the
guessed icecast mirrors. The rest of the app only sees
:class:`SomaFMProvider`, which implements
:class:`beefy_player.providers.base.RadioProvider`.
"""
from __future__ import annotations
import httpx
from ... import config
from ...models import Song, Station
from ..base import RadioProvider
from . import api, fallback
__all__ = ["SomaFMProvider"]
class SomaFMProvider(RadioProvider):
id = "somafm"
name = "SomaFM"
homepage = "somafm.com"
brand = "SOMA FM"
tagline = "listener-supported radio · san francisco"
station_source = "somafm.com/channels.json"
history_source = "songs/<station>.json"
@property
def user_agent(self) -> str:
# SomaFM asks clients to identify themselves, so say who we are and
# where we come from.
return f"{config.USER_AGENT}; +https://somafm.com"
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
return await api.fetch_channels(client)
def load_cached_stations(self) -> list[Station]:
return api.load_cached_channels()
def fallback_stations(self) -> list[Station]:
return api.parse_channels({"channels": fallback.fallback_channels()})
async def fetch_history(
self, client: httpx.AsyncClient, station_id: str
) -> list[Song]:
return await api.fetch_history(client, station_id)
def load_cached_history(self, station_id: str) -> list[Song]:
return api.load_cached_songs(station_id)
def fallback_urls(self, station: Station) -> list[str]:
return api.synthetic_mirrors(station.id)
+206
View File
@@ -0,0 +1,206 @@
"""The public SomaFM endpoints, and nothing but.
* ``https://somafm.com/channels.json`` -> every station + listener counts
* ``https://somafm.com/<station>.pls`` -> tiny PLS playlists (resolved to ice URLs)
* ``https://somafm.com/songs/<station>.json`` -> the "last played" song history
All parsing is deliberately forgiving: SomaFM has changed field names before
and we would rather show a slightly degraded station than crash. Nothing
outside :mod:`beefy_player.providers.somafm` should import this module — the
app talks to :class:`beefy_player.providers.somafm.SomaFMProvider` instead.
"""
from __future__ import annotations
import re
import time
from collections.abc import Iterable
from typing import Any
import httpx
from ... import config
from ...models import Song, Station, parse_date, parse_last_playing, to_int
from ..base import fetch_json, quote_station_id
CHANNELS_URL = "https://somafm.com/channels.json"
SONGS_URL = "https://somafm.com/songs/{station}.json"
# --------------------------------------------------------------------------- #
# channels
# --------------------------------------------------------------------------- #
def station_from_payload(item: dict[str, Any]) -> Station | None:
if not isinstance(item, dict):
return None
channel_id = item.get("id")
if not isinstance(channel_id, str) or not channel_id.strip():
return None
channel_id = channel_id.strip()
playlists: list[dict[str, str]] = []
raw_playlists = item.get("playlists")
if isinstance(raw_playlists, list):
for entry in raw_playlists:
if not isinstance(entry, dict):
continue
url = entry.get("url")
if not isinstance(url, str) or not url.startswith(("http://", "https://")):
continue
playlists.append(
{
"url": url,
"format": str(entry.get("format") or "").strip().lower(),
"quality": str(entry.get("quality") or "").strip().lower(),
}
)
station = Station(
id=channel_id,
title=str(item.get("title") or channel_id).strip(),
description=str(item.get("description") or "").strip(),
genre=str(item.get("genre") or "").strip(),
dj=str(item.get("dj") or "").strip(),
image=str(item.get("image") or item.get("largeimage") or "").strip(),
listeners=to_int(item.get("listeners")),
playlists=playlists,
provider="somafm",
)
last_playing = item.get("lastPlaying")
if isinstance(last_playing, str):
station.last_playing = last_playing.strip()
station.now_playing = parse_last_playing(station.last_playing)
if not station.image:
station.image = f"https://somafm.com/logos/256/{channel_id}256.png"
return station
def parse_channels(payload: Any) -> list[Station]:
if isinstance(payload, dict):
items = payload.get("channels")
elif isinstance(payload, list):
items = payload
else:
items = None
if not isinstance(items, list):
return []
stations: list[Station] = []
seen: set[str] = set()
for item in items:
station = station_from_payload(item)
if station is None or station.id in seen:
continue
seen.add(station.id)
stations.append(station)
return stations
async def fetch_channels(client: httpx.AsyncClient) -> list[Station]:
try:
payload = await fetch_json(client, CHANNELS_URL)
except RuntimeError as exc:
raise RuntimeError(f"could not fetch SomaFM channels: {exc}") from None
stations = parse_channels(payload)
if not stations:
raise RuntimeError("SomaFM returned an empty channel list")
config.write_json(config.CHANNELS_CACHE, payload)
return stations
def load_cached_channels() -> list[Station]:
return parse_channels(config.read_json(config.CHANNELS_CACHE))
# --------------------------------------------------------------------------- #
# song history
# --------------------------------------------------------------------------- #
def parse_songs(payload: Any) -> list[Song]:
if isinstance(payload, dict):
items = payload.get("songs")
elif isinstance(payload, list):
items = payload
else:
items = None
if not isinstance(items, list):
return []
songs: list[Song] = []
for entry in items:
if not isinstance(entry, dict):
continue
song = Song(
title=str(entry.get("title") or "").strip(),
artist=str(entry.get("artist") or "").strip(),
album=str(entry.get("album") or "").strip(),
art_url=str(
entry.get("albumArt") or entry.get("albumart") or entry.get("art") or ""
).strip(),
played_at=parse_date(entry.get("date")),
)
if song.title or song.artist:
songs.append(song)
# Newest first; the upstream feed is already sorted but let's be sure.
if all(song.played_at for song in songs):
songs.sort(key=lambda song: song.played_at, reverse=True) # type: ignore[arg-type,return-value]
return songs
def _songs_cache_path(station_id: str):
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", station_id)
return config.SONGS_CACHE_DIR / f"{safe}.json"
def load_cached_songs(station_id: str) -> list[Song]:
raw = config.read_json(_songs_cache_path(station_id))
if isinstance(raw, dict):
return parse_songs(raw.get("songs"))
return []
def _songs_to_cache(songs: Iterable[Song]) -> list[dict[str, Any]]:
return [
{
"title": song.title,
"artist": song.artist,
"album": song.album,
"albumArt": song.art_url,
"date": song.played_at.isoformat() if song.played_at else "",
}
for song in songs
]
async def fetch_history(client: httpx.AsyncClient, station_id: str) -> list[Song]:
url = SONGS_URL.format(station=quote_station_id(station_id))
try:
response = await client.get(url)
response.raise_for_status()
payload = response.json()
except (httpx.HTTPError, ValueError):
return load_cached_songs(station_id)
songs = parse_songs(payload)
if songs:
config.write_json(
_songs_cache_path(station_id),
{"fetched": time.time(), "songs": _songs_to_cache(songs)},
)
return songs
return load_cached_songs(station_id)
# --------------------------------------------------------------------------- #
# streams
# --------------------------------------------------------------------------- #
def synthetic_mirrors(station_id: str) -> list[str]:
"""Direct icecast URLs, handy when the PLS files are unreachable."""
urls: list[str] = []
for suffix in ("-128-mp3", "-64-aac"):
for host in ("ice1", "ice2", "ice3", "ice4"):
urls.append(f"https://{host}.somafm.com/{station_id}{suffix}")
return urls
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
@@ -47,9 +47,19 @@ MAX_FREQ = 16000.0
SOURCE_MODES = ("auto", "monitor", "stream", "sim") SOURCE_MODES = ("auto", "monitor", "stream", "sim")
#: ~46 ms of all-zero audio per block; give an output capture ~6 s to prove #: ~46 ms of all-zero audio per block; give an output capture ~6 s to prove
#: itself before falling back to the stream tap. #: itself before falling back to the stream tap. This only applies before the
#: capture has ever delivered real audio. Once a capture has worked, silence
#: means the output really is silent (or mpv is rebuffering), so SYNC is kept
#: and the bars simply fall instead of switching to the drifting stream tap.
SILENT_MONITOR_BLOCKS = 130 SILENT_MONITOR_BLOCKS = 130
#: How long a monitor may deliver no data at all before the stream tap takes
#: over. Kept longer than a normal station change.
MONITOR_IDLE_SECONDS = 10.0
#: How often a quiet monitor pipe is polled so stop/URL changes stay responsive.
MONITOR_POLL_SECONDS = 0.5
#: Frames per analysis block for the macOS tap (float32, ~43 ms at 48 kHz). #: Frames per analysis block for the macOS tap (float32, ~43 ms at 48 kHz).
TAP_BLOCK_FRAMES = 2048 TAP_BLOCK_FRAMES = 2048
@@ -58,7 +68,7 @@ TAP_BLOCK_FRAMES = 2048
#: the window playback gets to start before the stream tap takes over. #: the window playback gets to start before the stream tap takes over.
TAP_IDLE_SECONDS = 10.0 TAP_IDLE_SECONDS = 10.0
#: How long the stream tap covers for us before the tap is tried again. #: How long the stream tap covers for us before an output capture is retried.
TAP_RETRY_SECONDS = 60.0 TAP_RETRY_SECONDS = 60.0
@@ -118,8 +128,18 @@ class SpectrumEngine:
self._url: str | None = None self._url: str | None = None
self._proc: subprocess.Popen[bytes] | None = None self._proc: subprocess.Popen[bytes] | None = None
self._capture_kind: str | None = None self._capture_kind: str | None = None
#: Incremented whenever the source changes; captures from an older
#: generation must not affect the new one (or latch a failure).
self._generation = 0
self._reset_pending = False
#: True once any output capture has ever delivered real audio. A later
#: silent capture is then a gap, not a misconfigured device.
self._monitor_worked = False
self._monitor_failed = False self._monitor_failed = False
#: Next time an output capture may be tried (shared by parec and tap).
self._tap_retry_at = 0.0 self._tap_retry_at = 0.0
#: Why ``_capture`` last returned ("eof", "idle", "silent", ...).
self._capture_reason = "eof"
self._monitor_device_cache: str | None = None self._monitor_device_cache: str | None = None
self._notice: str | None = None self._notice: str | None = None
self._lock = threading.Lock() self._lock = threading.Lock()
@@ -144,25 +164,42 @@ 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()
def set_source(self, url: str | None) -> None: def set_source(self, url: str | None) -> None:
"""Start analysing playback, or idle when ``url`` is ``None``. """Start analysing playback, or idle when ``url`` is ``None``.
The URL is only a fallback source for the stream tap. A monitor A station change is a clean slate: any capture belonging to the old
capture is tied to the actual audio output, so it deliberately keeps station is retired, a previous monitor failure is forgotten, and the
running when the station changes underneath it. analyser's display state is reset. That way every tune gets a fresh
chance to lock onto the real output (SYNC) instead of inheriting the
drifting stream tap (FFT) or the synthesized fallback (SIM).
""" """
url = url or None url = url or None
with self._lock: with self._lock:
changed = url != self._url changed = url != self._url
self._url = url self._url = url
kind = self._capture_kind if changed:
self._request_reset()
if url is None: if url is None:
self._status = "idle" self._status = "idle"
if changed and (url is None or kind != "monitor"): if changed:
# Unblock a stream read and retire a subprocess monitor. A tap has
# no process; its generation check makes it stop and restart.
self._kill_proc()
def reset(self) -> None:
"""Give the output capture a clean slate (called when tuning).
Forgets a previous monitor failure - including a hard one that set
``_monitor_failed`` - and clears the displayed state, so the next
capture may use the real output (SYNC) again. Unlike :meth:`set_source`
this does not change the source, so re-tuning the same station works.
"""
with self._lock:
self._request_reset()
self._kill_proc() self._kill_proc()
def snapshot(self) -> Frame: def snapshot(self) -> Frame:
@@ -213,6 +250,41 @@ class SpectrumEngine:
with self._lock: with self._lock:
return self._url return self._url
def _request_reset(self) -> None:
"""Arm a clean restart; the caller must hold ``self._lock``."""
self._generation += 1
self._reset_pending = True
self._monitor_failed = False
self._tap_retry_at = 0.0
self._capture_reason = "eof"
# The default sink may have changed along with the stream; resolve it
# again for the next monitor capture.
self._monitor_device_cache = None
def _read_generation(self) -> int:
with self._lock:
return self._generation
def _take_reset(self) -> bool:
with self._lock:
pending, self._reset_pending = self._reset_pending, False
return pending
def _reset_analysis(self) -> None:
"""Drop the previous station's samples and display state.
Only called from the analyser thread (or before it starts), so the
history buffer needs no lock; the values read by ``snapshot`` do.
"""
self._history[:] = 0.0
self._sim_phase = 0.0
with self._lock:
self._levels[:] = 0.0
self._peaks[:] = 0.0
self._wave[:] = 0.0
self._level = 0.0
self._gain = -24.0
def _kill_proc(self) -> None: def _kill_proc(self) -> None:
with self._lock: with self._lock:
proc = self._proc proc = self._proc
@@ -227,13 +299,11 @@ class SpectrumEngine:
def _use_monitor(self) -> bool: def _use_monitor(self) -> bool:
if self._monitor_failed or self.source_mode in ("sim", "stream"): if self._monitor_failed or self.source_mode in ("sim", "stream"):
return False return False
kind = self._monitor_kind() if self._monitor_kind() is None:
if kind is None:
return False return False
if kind != "tap": # A capture that had nothing to give us (a station change leaves the
return True # sink quiet while mpv buffers, a tap is idle before playback starts)
# The tap needs the default output to actually render something; when # is retried later instead of being written off for the session.
# it does not, the stream tap covers us until the retry deadline.
return time.monotonic() >= self._tap_retry_at return time.monotonic() >= self._tap_retry_at
def _monitor_kind(self) -> str | None: def _monitor_kind(self) -> str | None:
@@ -245,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:
@@ -281,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(
@@ -316,6 +386,8 @@ class SpectrumEngine:
def _run(self) -> None: def _run(self) -> None:
while not self._stop.is_set(): while not self._stop.is_set():
if self._take_reset():
self._reset_analysis()
url = self._read_url() url = self._read_url()
if not url: if not url:
self._idle_frame() self._idle_frame()
@@ -327,15 +399,21 @@ class SpectrumEngine:
continue continue
if self._use_monitor(): if self._use_monitor():
generation = self._read_generation()
outcome = self._capture_monitor(url) outcome = self._capture_monitor(url)
if generation != self._read_generation():
# The station changed while this capture was running; its
# result belongs to the old source. Start over cleanly.
continue
if outcome == "done": if outcome == "done":
continue continue
if outcome == "retry": if outcome == "retry":
# Nothing was rendered (yet): try the tap again later. # Nothing was rendered (yet), or the sink went quiet while
# the station changed: try the output capture again later.
self._tap_retry_at = time.monotonic() + TAP_RETRY_SECONDS self._tap_retry_at = time.monotonic() + TAP_RETRY_SECONDS
else: else:
# Device gone, or the macOS audio-capture permission was # Hard failure (the capture never produced audio): do not
# never granted: do not retry in this session. # retry until the source changes (which resets this).
self._monitor_failed = True self._monitor_failed = True
if self._stop.is_set(): if self._stop.is_set():
break break
@@ -343,20 +421,36 @@ class SpectrumEngine:
continue continue
if not self._ffmpeg: if not self._ffmpeg:
self._simulate_for(url, 0.05) # Keep re-checking: a pending output-capture retry may become
# due while we are showing the synthesized fallback.
self._simulate_for(url, 0.05, duration=5.0)
continue continue
generation = self._read_generation()
proc = self._spawn(self._command(url), "stream") proc = self._spawn(self._command(url), "stream")
if proc is None: if proc is None:
with self._lock: with self._lock:
self._status = "sim" self._status = "sim"
self._simulate_for(url, 0.06, duration=5.0) self._simulate_for(url, 0.06, duration=5.0)
continue continue
self._capture(proc, url, stop_on_url_change=True, status="live") self._capture(
proc,
url,
stop_on_url_change=True,
status="live",
generation=generation,
)
self._stop_proc(proc) self._stop_proc(proc)
if self._stop.is_set(): if self._stop.is_set():
break break
if generation != self._read_generation():
continue
# The stream tap only covers periods where no output capture is
# available. Once the retry deadline has passed, go back around
# and try the real thing instead of simulating on top of the tap.
if self._use_monitor():
continue
if self._read_url() == url: if self._read_url() == url:
with self._lock: with self._lock:
self._status = "sim" self._status = "sim"
@@ -407,25 +501,44 @@ class SpectrumEngine:
"""Capture the real audio output. """Capture the real audio output.
Returns ``"done"`` when playback stopped, ``"failed"`` when the Returns ``"done"`` when playback stopped, ``"failed"`` when the
monitor is unusable, and ``"retry"`` when it merely had nothing to capture never produced audio (the device is gone or the sink is not
capture yet. routed into it), and ``"retry"`` when it is temporarily unavailable.
""" """
generation = self._read_generation()
if self._monitor_kind() == "tap": if self._monitor_kind() == "tap":
return self._capture_tap() outcome = self._capture_tap()
if generation != self._read_generation():
return "done"
return outcome
command = self._monitor_command() command = self._monitor_command()
proc = self._spawn(command, "monitor") if command else None proc = self._spawn(command, "monitor") if command else None
if proc is None: if proc is None:
return "failed" return "failed"
try: try:
self._capture(proc, url, stop_on_url_change=False, status="monitor") self._capture(
proc,
url,
stop_on_url_change=False,
status="monitor",
generation=generation,
)
finally: finally:
self._stop_proc(proc) self._stop_proc(proc)
if generation != self._read_generation():
# Deliberately stopped/restarted between stations; not a failure.
return "done"
if self._stop.is_set() or self._read_url() is None: if self._stop.is_set() or self._read_url() is None:
return "done" return "done"
# A sink that is quiet for a moment (mpv rebuffers a new station, a
# device is briefly suspended) is not broken: let the stream tap cover
# us until the next retry instead of permanently writing SYNC off.
if self._capture_reason == "idle":
return "retry"
return "failed" return "failed"
def _capture_tap(self) -> str: def _capture_tap(self) -> str:
"""Capture the native macOS output tap.""" """Capture the native macOS output tap."""
generation = self._read_generation()
tap = macos_tap.SystemAudioTap() tap = macos_tap.SystemAudioTap()
try: try:
tap.start() tap.start()
@@ -438,21 +551,28 @@ class SpectrumEngine:
self._status = "monitor" self._status = "monitor"
try: try:
self._retune(tap.sample_rate) self._retune(tap.sample_rate)
return self._pump_tap(tap) return self._pump_tap(tap, generation)
finally: finally:
tap.stop() tap.stop()
with self._lock: with self._lock:
if self._capture_kind == "monitor": if self._capture_kind == "monitor":
self._capture_kind = None self._capture_kind = None
def _pump_tap(self, tap: macos_tap.SystemAudioTap) -> str: def _pump_tap(
self, tap: macos_tap.SystemAudioTap, generation: int | None = None
) -> str:
"""Feed tap audio to the analyser until playback stops or it fails.""" """Feed tap audio to the analyser until playback stops or it fails."""
if generation is None:
generation = self._read_generation()
channels = max(1, tap.channels) channels = max(1, tap.channels)
block_bytes = TAP_BLOCK_FRAMES * max(4, tap.bytes_per_frame) block_bytes = TAP_BLOCK_FRAMES * max(4, tap.bytes_per_frame)
buffer = bytearray() buffer = bytearray()
silent_blocks = 0 silent_blocks = 0
ever_worked = self._monitor_worked
last_audio = time.monotonic() last_audio = time.monotonic()
while not self._stop.is_set(): while not self._stop.is_set():
if self._read_generation() != generation:
return "done"
if self._read_url() is None: if self._read_url() is None:
return "done" return "done"
chunk = tap.read(0.5) chunk = tap.read(0.5)
@@ -473,19 +593,24 @@ class SpectrumEngine:
while len(buffer) >= block_bytes: while len(buffer) >= block_bytes:
block = bytes(buffer[:block_bytes]) block = bytes(buffer[:block_bytes])
del buffer[:block_bytes] del buffer[:block_bytes]
# A tap that was never granted permission is created happily
# but only ever hands out digital silence; detect that here.
if not block.strip(b"\x00"): if not block.strip(b"\x00"):
silent_blocks += 1 silent_blocks += 1
if silent_blocks >= SILENT_MONITOR_BLOCKS: # A tap that was never granted permission is created
# happily but only ever hands out digital silence; detect
# that here. Silence after real audio, however, is just a
# station change or an actually silent output, so keep
# monitoring (the bars fall by themselves).
if not ever_worked and silent_blocks >= SILENT_MONITOR_BLOCKS:
self._notify( self._notify(
"macOS is not letting the app record audio — allow " "macOS is not letting the app record audio — allow "
"'System Audio Recording' for your terminal in " "'System Audio Recording' for your terminal in "
"System Settings, then restart (using FFT)" "System Settings, then restart (using FFT)"
) )
return "failed" return "failed"
continue else:
silent_blocks = 0 silent_blocks = 0
ever_worked = True
self._monitor_worked = True
self.feed_audio(block, channels, "monitor") self.feed_audio(block, channels, "monitor")
return "done" return "done"
@@ -496,56 +621,103 @@ class SpectrumEngine:
*, *,
stop_on_url_change: bool, stop_on_url_change: bool,
status: str, status: str,
generation: int | None = None,
) -> bool: ) -> bool:
"""Read PCM until playback stops, the source changes, or the process dies.""" """Read PCM until playback stops, the source changes, or the process dies.
``self._capture_reason`` records why the loop ended so the caller can
tell a capture that has gone for good (``"eof"``/``"dead"``) from one
that is merely quiet while a station is being tuned (``"idle"`` /
``"restart"``).
"""
stdout = proc.stdout stdout = proc.stdout
if stdout is None: if stdout is None:
self._capture_reason = "eof"
return False return False
if generation is None:
generation = self._read_generation()
fd = stdout.fileno() fd = stdout.fileno()
got_data = False got_data = False
buffer = bytearray() buffer = bytearray()
silent_blocks = 0 silent_blocks = 0
ever_worked = self._monitor_worked
last_data = time.monotonic()
reason = "stop"
while not self._stop.is_set(): while not self._stop.is_set():
if self._read_generation() != generation:
reason = "restart"
break
current = self._read_url() current = self._read_url()
if current is None: if current is None:
reason = "idle"
break break
if stop_on_url_change and current != url: if stop_on_url_change and current != url:
reason = "url"
break
# The stream tap yields once an output capture is due for a retry,
# so a monitor with a momentary lapse does not keep us on the
# (drifting) stream tap for the rest of the session.
if status == "live" and self._use_monitor():
reason = "monitor"
break break
# A monitor can go quiet if the device disappears; don't block the # A monitor can go quiet while mpv rebuffers a newly tuned station,
# analyser thread forever. os.read returns whatever is available # or when the device disappears. Don't block the analyser thread
# (instead of waiting for a full 4 KiB), which keeps latency low. # forever, but give silence a fair chance to turn back into audio.
# os.read returns whatever is available (instead of waiting for a
# full 4 KiB), which keeps latency low.
if status == "monitor" and os.name == "posix": if status == "monitor" and os.name == "posix":
try: try:
ready, _, _ = select.select([fd], [], [], 2.0) ready, _, _ = select.select(
[fd], [], [], MONITOR_POLL_SECONDS
)
except (OSError, ValueError): except (OSError, ValueError):
ready = [fd] ready = [fd]
if not ready: if not ready:
if proc.poll() is not None:
reason = "eof"
break break
if time.monotonic() - last_data >= MONITOR_IDLE_SECONDS:
reason = "idle"
break
continue
try: try:
data = os.read(fd, READ_BYTES) data = os.read(fd, READ_BYTES)
except OSError: except OSError:
reason = "eof"
break break
if not data: if not data:
reason = "eof"
break break
now = time.monotonic()
last_data = now
buffer.extend(data) buffer.extend(data)
# Process fixed-size blocks so attack/decay smoothing is tied to # Process fixed-size blocks so attack/decay smoothing is tied to
# time (~46 ms per block), regardless of how the pipe fragments. # time (~46 ms per block), regardless of how the pipe fragments.
while len(buffer) >= READ_BYTES: while len(buffer) >= READ_BYTES:
block = bytes(buffer[:READ_BYTES]) block = bytes(buffer[:READ_BYTES])
del buffer[:READ_BYTES] del buffer[:READ_BYTES]
# A monitor that was never granted permission (macOS) or that
# nobody routes into (PulseAudio) delivers endless digital
# silence. SYNC bars that never move are worse than the
# (slightly drifting) stream tap, so give up and let the
# caller fall back.
if status == "monitor": if status == "monitor":
silent_blocks = silent_blocks + 1 if block.count(0) == len(block) else 0 if block.count(0) == len(block):
if silent_blocks >= SILENT_MONITOR_BLOCKS: silent_blocks += 1
else:
silent_blocks = 0
ever_worked = True
self._monitor_worked = True
# A capture that was never granted permission (macOS) or
# that nobody routes into (PulseAudio) delivers endless
# digital silence. SYNC bars that never move are worse
# than the (slightly drifting) stream tap, so give up on a
# backend that has never worked. Once it has worked,
# silence is real (or mpv rebuffering): keep SYNC and let
# the bars fall.
if not ever_worked and silent_blocks >= SILENT_MONITOR_BLOCKS:
self._capture_reason = "dead"
return False return False
got_data = True got_data = True
self.feed_pcm(block, status) self.feed_pcm(block, status)
self._capture_reason = reason
return got_data return got_data
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -668,6 +840,10 @@ class SpectrumEngine:
while not self._stop.is_set(): while not self._stop.is_set():
if self._read_url() != url: if self._read_url() != url:
return return
# Stop pretending as soon as a real output capture is available
# again (e.g. the retry deadline expired during the fallback).
if self._use_monitor():
return
self._simulate_step() self._simulate_step()
if self._stop.wait(delay): if self._stop.wait(delay):
return return
@@ -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 {
+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),) _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
@@ -4,6 +4,7 @@ from __future__ import annotations
import sys import sys
import textwrap import textwrap
from typing import TYPE_CHECKING, cast
from rich.console import Group from rich.console import Group
from rich.table import Table from rich.table import Table
@@ -14,6 +15,9 @@ from textual.widgets import Static
from .common import SPECTRUM_STOPS, gradient from .common import SPECTRUM_STOPS, gradient
if TYPE_CHECKING:
from ..app import BeefyPlayerApp
HELP_ROWS: tuple[tuple[str, str], ...] = ( HELP_ROWS: tuple[tuple[str, str], ...] = (
("↑ / ↓ j / k", "move through the station list"), ("↑ / ↓ j / k", "move through the station list"),
("enter", "tune in to the selected station"), ("enter", "tune in to the selected station"),
@@ -25,20 +29,16 @@ HELP_ROWS: tuple[tuple[str, str], ...] = (
("f", "favourite — favourites float to the top"), ("f", "favourite — favourites float to the top"),
("/", "filter stations by title, genre or description"), ("/", "filter stations by title, genre or description"),
("esc", "clear the filter / close overlays"), ("esc", "clear the filter / close overlays"),
("v", "cycle visualiser: mirrored bars → bars → wave"), (
"v",
"cycle visualiser: mirror → bars → wave → waterfall → radial → braille",
),
("r", "refresh station list and song history"), ("r", "refresh station list and song history"),
("g / G", "jump to the top / bottom of the list"), ("g / G", "jump to the top / bottom of the list"),
("? ", "this help"), ("? ", "this help"),
("q", "quit"), ("q", "quit"),
) )
ABOUT_COMMON = (
(
"Stations and song history come from somafm.com "
"(channels.json + songs/<station>.json)."
),
)
ABOUT_LINUX = ( ABOUT_LINUX = (
( (
"The spectrum captures your actual PulseAudio/PipeWire output with " "The spectrum captures your actual PulseAudio/PipeWire output with "
@@ -47,7 +47,7 @@ ABOUT_LINUX = (
), ),
( (
"The desktop picks us up over MPRIS as a " "The desktop picks us up over MPRIS as a "
"somafmtui media player — try `playerctl -l` / `playerctl metadata`." "beefyplayer media player — try `playerctl -l` / `playerctl metadata`."
), ),
) )
@@ -65,11 +65,15 @@ ABOUT_MACOS = (
), ),
) )
ABOUT = ABOUT_COMMON + (ABOUT_MACOS if sys.platform == "darwin" else ABOUT_LINUX)
class HelpScreen(ModalScreen[None]): class HelpScreen(ModalScreen[None]):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
provider = cast("BeefyPlayerApp", self.app).provider
about = (
f"Stations and song history come from {provider.name} "
f"({provider.station_source} + {provider.history_source}).",
) + (ABOUT_MACOS if sys.platform == "darwin" else ABOUT_LINUX)
table = Table.grid(padding=(0, 2), expand=False) table = Table.grid(padding=(0, 2), expand=False)
table.add_column(style="bold cyan", no_wrap=True, justify="right") table.add_column(style="bold cyan", no_wrap=True, justify="right")
table.add_column(style="") table.add_column(style="")
@@ -78,11 +82,11 @@ class HelpScreen(ModalScreen[None]):
header = Text() header = Text()
header.append("◤ ", style=f"bold {gradient(SPECTRUM_STOPS, 0.9)}") header.append("◤ ", style=f"bold {gradient(SPECTRUM_STOPS, 0.9)}")
header.append("SOMAFM TUI", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}") header.append("BEEFY PLAYER", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}")
header.append(" keyboard & credits", style="dim") header.append(" keyboard & credits", style="dim")
footer = Text() footer = Text()
for paragraph in ABOUT: for paragraph in about:
for line in textwrap.wrap(paragraph, width=70): for line in textwrap.wrap(paragraph, width=70):
footer.append(line + "\n", style="dim") footer.append(line + "\n", style="dim")
File renamed without changes.
+408
View File
@@ -0,0 +1,408 @@
"""The flashy bit: an ANSI-colour spectrum analyser / oscilloscope."""
from __future__ import annotations
import math
from collections import deque
from collections.abc import Sequence
from typing import TYPE_CHECKING, cast
from rich.console import Group, RenderableType
from rich.text import Text
from textual.widget import Widget
from ..spectrum import Frame, SpectrumEngine
from .common import LEVELS, SPECTRUM_STOPS, gradient
if TYPE_CHECKING:
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.
WATERFALL_HISTORY = 512
WATERFALL_LEVELS = " ░▒▓█"
#: Braille dot bit value, indexed by sub-row and sub-column inside a cell.
BRAILLE_BITS = (
(0x01, 0x08),
(0x02, 0x10),
(0x04, 0x20),
(0x40, 0x80),
)
VISUALIZER_LABELS = {
"both": "MIRROR",
"bars": "BARS",
"wave": "WAVE",
"waterfall": "WATERFALL",
"radial": "RADIAL",
"braille": "BRAILLE",
}
class SpectrumPanel(Widget):
can_focus = False
def __init__(self, engine: SpectrumEngine) -> None:
super().__init__()
self.engine = engine
self.border_title = "SPECTRUM"
self._palettes: dict[tuple[int, int, bool], list[str]] = {}
#: Recent band frames, newest last, for the waterfall display. Filled
#: even while another mode is showing so switching to it has history.
self._history: deque[tuple[float, ...]] = deque(maxlen=WATERFALL_HISTORY)
self._history_tick: int | None = None
# Called from the app ticker so the border title tracks the analyser mode.
def sync(self, mode: str, status: str) -> None:
label = VISUALIZER_LABELS.get(mode, mode.upper())
title = f"SPECTRUM · {label} · {status}"
if self.border_title != title:
self.border_title = title
self.refresh()
def render(self) -> RenderableType:
app = cast("BeefyPlayerApp", self.app)
frame = self.engine.snapshot()
width = max(8, self.size.width)
height = max(2, self.size.height)
mode = getattr(app, "visualizer", "both")
phase = int(getattr(app, "tick_count", 0) // 2)
dim = frame.status == "idle"
self._record(frame, int(getattr(app, "tick_count", 0)))
lines: list[Text]
if mode == "wave":
lines = self._wave(frame, width, height, phase, dim)
elif mode == "braille":
lines = self._braille(frame, width, height, phase, dim)
elif mode == "waterfall":
lines = self._waterfall(width, height, dim)
elif mode == "radial":
lines = self._radial(frame, width, height, phase, dim)
elif mode == "bars" or height < 8:
lines = self._bars(frame, width, height, phase, dim)
else:
# Default mode: a full-height mirrored spectrum. The top half
# grows up from the centre line, the bottom half grows down from
# it (same values, flipped rows, dimmed like a reflection).
top_height = height // 2
bottom_height = height - top_height
lines = self._bars(frame, width, top_height, phase, dim)
mirrored = self._bars(
frame, width, bottom_height, phase + 5, True, peaks=False
)
mirrored.reverse()
lines.extend(mirrored)
return Group(*lines)
# ------------------------------------------------------------------ #
# history
# ------------------------------------------------------------------ #
def _record(self, frame: Frame, tick: int) -> None:
"""Remember one column per analyser tick (not one per render)."""
if tick == self._history_tick:
return
self._history_tick = tick
self._history.append(frame.bands)
# ------------------------------------------------------------------ #
# drawing
# ------------------------------------------------------------------ #
@staticmethod
def _sample(values: Sequence[float], position: float) -> float:
"""Linearly interpolate ``values`` at a fractional index."""
if not values:
return 0.0
last = len(values) - 1
position = max(0.0, min(float(last), position))
low = int(position)
high = min(low + 1, last)
fraction = position - low
return float(values[low]) * (1.0 - fraction) + float(values[high]) * fraction
def _row_palette(self, height: int, phase: int, dim: bool) -> list[str]:
key = (height, phase % 12, dim)
cached = self._palettes.get(key)
if cached is not None:
return cached
palette: list[str] = []
for row in range(height):
position = (height - 1 - row) / max(1, height - 1)
colour = gradient(SPECTRUM_STOPS, position)
style = f"bold {colour}" if (row + phase) % 7 == 0 else colour
if dim:
style = f"dim {style}"
palette.append(style)
if len(self._palettes) > 96:
self._palettes.clear()
self._palettes[key] = palette
return palette
@staticmethod
def _resample(values: Sequence[float], width: int) -> list[float]:
count = len(values)
if count == 0:
return [0.0] * width
if width <= 1 or count == 1:
return [float(values[0])] * width
last = count - 1
output: list[float] = []
for column in range(width):
position = column * last / (width - 1)
low = int(position)
high = min(low + 1, last)
fraction = position - low
output.append(values[low] * (1.0 - fraction) + values[high] * fraction)
return output
@staticmethod
def _resample_mean(values: Sequence[float], width: int) -> list[float]:
count = len(values)
if count == 0:
return [0.0] * width
output: list[float] = []
for column in range(width):
low = int(column * count / width)
high = max(low + 1, int((column + 1) * count / width))
high = min(high, count)
chunk = values[low:high]
output.append(sum(chunk) / len(chunk) if chunk else 0.0)
return output
def _bars(
self,
frame: Frame,
width: int,
height: int,
phase: int,
dim: bool,
peaks: bool = True,
) -> list[Text]:
values = self._resample(frame.bands, width)
peaks = self._resample(frame.peaks, width)
palette = self._row_palette(height, phase, dim)
scale = height * 8.0
lines: list[Text] = []
for row in range(height):
line = Text(no_wrap=True)
row_top = (height - row - 1) * 8.0
style = palette[row]
for column in range(width):
units = min(values[column], 1.0) * scale - row_top
peak_units = min(peaks[column], 1.0) * scale - row_top
if units >= 8.0:
line.append("█", style=style)
elif units > 0.0:
level = max(1, min(7, int(units)))
line.append(LEVELS[level], style=style)
elif (
peaks
and 0.0 <= peak_units < 8.0
and peak_units > units + 1.0
):
line.append("▁", style="dim" if dim else "bold")
else:
line.append(" ", style="")
lines.append(line)
return lines
def _wave_values(self, frame: Frame, width: int) -> list[float]:
"""The waveform resampled to ``width`` points, with display gain.
The engine normalises each chunk, but smoothing and averaging make the
trace sit around the middle. A gentle display gain lets an oscilloscope
use the full height without amplifying silence into noise.
"""
wave = self._resample_mean(frame.wave, width)
peak = max((abs(value) for value in wave), default=0.0)
if peak > 0.02:
gain = min(2.5, 0.9 / peak)
wave = [value * gain for value in wave]
return wave
def _wave(
self, frame: Frame, width: int, height: int, phase: int, dim: bool
) -> list[Text]:
wave = self._wave_values(frame, width)
palette = self._row_palette(height, phase, dim)
centre = (height - 1) / 2.0
amplitude = max(0.75, (height - 1) / 2.0)
grid = [[" " for _ in range(width)] for _ in range(height)]
previous_row: int | None = None
for column, value in enumerate(wave):
clamped = max(-1.0, min(1.0, float(value)))
position = centre - clamped * amplitude
row = max(0, min(height - 1, round(position)))
grid[row][column] = "█"
delta = position - row
if delta > 0.35 and row + 1 < height:
grid[row + 1][column] = "▄"
elif delta < -0.35 and row - 1 >= 0:
grid[row - 1][column] = "▀"
if previous_row is not None and previous_row != row:
low, high = sorted((previous_row, row))
for between in range(low + 1, high):
if grid[between][column] == " ":
grid[between][column] = "│"
previous_row = row
lines: list[Text] = []
for row in range(height):
line = Text(no_wrap=True)
style = palette[row]
for char in grid[row]:
if char == " ":
line.append(" ", style="")
else:
line.append(char, style=style)
lines.append(line)
return lines
def _braille(
self, frame: Frame, width: int, height: int, phase: int, dim: bool
) -> list[Text]:
"""Oscilloscope drawn with braille dots: 2x4 sub-cells per character."""
dots_width = width * 2
dots_height = height * 4
values = self._wave_values(frame, dots_width)
centre = (dots_height - 1) / 2.0
amplitude = max(1.0, (dots_height - 1) / 2.0)
cells = [[0] * width for _ in range(height)]
def set_dot(x: int, y: int) -> None:
cells[y // 4][x // 2] |= BRAILLE_BITS[y % 4][x % 2]
previous: int | None = None
for column, value in enumerate(values):
clamped = max(-1.0, min(1.0, float(value)))
row = max(0, min(dots_height - 1, round(centre - clamped * amplitude)))
set_dot(column, row)
# Join steep segments so the trace stays continuous.
if previous is not None and previous != row:
low, high = sorted((previous, row))
for between in range(low + 1, high):
set_dot(column, between)
previous = row
palette = self._row_palette(height, phase, dim)
lines: list[Text] = []
for row in range(height):
line = Text(no_wrap=True)
style = palette[row]
for bits in cells[row]:
if bits:
line.append(chr(0x2800 | bits), style=style)
else:
line.append(" ", style="")
lines.append(line)
return lines
def _waterfall(self, width: int, height: int, dim: bool) -> list[Text]:
"""Scrolling spectrogram: history left-to-right, frequency bottom-up."""
columns = list(self._history)[-width:]
if not columns:
columns = [()]
last = max(1, len(columns[-1]) - 1)
# Row 0 is the treble end; the bass sits on the bottom row.
positions = [
(height - 1 - row) / max(1, height - 1) * last for row in range(height)
]
offset = width - len(columns)
fade_after = max(3, int(len(columns) * 0.75))
grid = [[" "] * width for _ in range(height)]
styles = [[""] * width for _ in range(height)]
for index, column in enumerate(columns):
if not column:
continue
x = offset + index
age = len(columns) - index
faded = dim or age > fade_after
for row, position in enumerate(positions):
value = self._sample(column, position)
level = int(value * (len(WATERFALL_LEVELS) - 1))
if level <= 0:
continue
style = gradient(SPECTRUM_STOPS, value)
if faded:
style = f"dim {style}"
elif age == 1:
# The newest column is the leading edge of the scan.
style = f"bold {style}"
grid[row][x] = WATERFALL_LEVELS[level]
styles[row][x] = style
lines: list[Text] = []
for row in range(height):
line = Text(no_wrap=True)
for x in range(width):
char = grid[row][x]
line.append(char, style=styles[row][x] if char != " " else "")
lines.append(line)
return lines
def _radial(
self, frame: Frame, width: int, height: int, phase: int, dim: bool
) -> list[Text]:
"""The spectrum wrapped into a glowing circle around the centre."""
centre_x = (width - 1) / 2.0
centre_y = (height - 1) / 2.0
# Terminal cells are about twice as tall as they are wide, so the
# horizontal radius is twice the vertical one to draw a round circle.
radius_y = min(centre_y, centre_x / 2.0)
if radius_y < 2.5:
return self._bars(frame, width, height, phase, dim)
radius_x = radius_y * 2.0
bands = list(frame.bands)
count = max(1, len(bands))
hole = 0.18
grid = [[" "] * width for _ in range(height)]
styles = [[""] * width for _ in range(height)]
for row in range(height):
dy = (row - centre_y) / radius_y
if abs(dy) > 1.0:
continue
for column in range(width):
dx = (column - centre_x) / radius_x
distance = math.hypot(dx, dy)
if distance > 1.0:
continue
angle = (math.atan2(dy, dx) + math.pi / 2.0) % (2.0 * math.pi)
band = bands[int(angle / (2.0 * math.pi) * count) % count]
edge = hole + max(0.0, min(1.0, float(band))) * (1.0 - hole)
if distance <= hole + 0.02:
# A faint inner ring gives silent stations a focus.
if distance > hole * 0.35:
grid[row][column] = "·"
styles[row][column] = "dim"
continue
if distance > edge:
continue
depth = edge - distance
if depth < 0.06:
grid[row][column] = "█"
styles[row][column] = ("dim " if dim else "") + gradient(
SPECTRUM_STOPS, edge
)
elif depth < 0.15:
grid[row][column] = "▓"
styles[row][column] = "dim " + gradient(SPECTRUM_STOPS, edge)
elif depth < 0.25:
grid[row][column] = "▒"
styles[row][column] = "dim"
lines: list[Text] = []
for row in range(height):
line = Text(no_wrap=True)
for column in range(width):
char = grid[row][column]
line.append(char, style=styles[row][column] if char != " " else "")
lines.append(line)
return lines
@@ -11,7 +11,7 @@ from textual.containers import Horizontal
from textual.css.query import NoMatches from textual.css.query import NoMatches
from textual.widget import Widget from textual.widget import Widget
from ..api import Song, Station from ..models import Song, Station
from .art import ArtPane from .art import ArtPane
from .common import ( from .common import (
compact_count, compact_count,
@@ -23,7 +23,7 @@ from .common import (
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from ..app import SomaFMApp from ..app import BeefyPlayerApp
class StagePanel(Widget): class StagePanel(Widget):
@@ -75,27 +75,30 @@ class StageInfo(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(8, self.size.width) width = max(8, self.size.width)
height = max(1, self.size.height) height = max(1, self.size.height)
station = app.current station = app.current
if station is None: if station is None:
return Group(*self._splash(width, height)) return Group(*self._splash(app, width, height))
details = self._details(app, station, station.now_playing, width) details = self._details(app, station, station.now_playing, width)
return Group(*details[:height]) return Group(*details[:height])
def _splash(self, width: int, height: int) -> list[Text]: def _splash(
self, app: BeefyPlayerApp, width: int, height: int
) -> list[Text]:
provider = app.provider
lines = [ lines = [
Text("◤ SOMA FM ◢", style="bold magenta"), Text(f"◤ {provider.brand} ◢", style="bold magenta"),
Text("listener-supported radio · san francisco", style="italic dim"), Text(provider.tagline, style="italic dim"),
Text(""), Text(""),
Text("↑ ↓ choose a station", style=""), Text("↑ ↓ choose a station", style=""),
Text("⏎ tune in", style=""), Text("⏎ tune in", style=""),
Text("? all keyboard shortcuts", style=""), Text("? all keyboard shortcuts", style=""),
Text(""), Text(""),
Text("Stations load from somafm.com/channels.json", style="dim"), Text(f"Stations load from {provider.station_source}", style="dim"),
] ]
return [ return [
Text(truncate(line.plain, width), style=line.style) Text(truncate(line.plain, width), style=line.style)
@@ -104,7 +107,7 @@ class StageInfo(Widget):
def _details( def _details(
self, self,
app: SomaFMApp, app: BeefyPlayerApp,
station: Station, station: Station,
song: Song | None, song: Song | None,
width: int, width: int,
@@ -188,7 +191,7 @@ class HistoryPane(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(8, self.size.width) width = max(8, self.size.width)
height = max(1, self.size.height) height = max(1, self.size.height)
station = app.current station = app.current
@@ -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
View File
@@ -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"]
+7 -7
View File
@@ -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)"
-3
View File
@@ -1,3 +0,0 @@
"""A flashy terminal radio player for somafm.com."""
__version__ = "0.1.0"
-463
View File
@@ -1,463 +0,0 @@
"""Async client for the public SomaFM endpoints.
The app talks to three things:
* ``https://somafm.com/channels.json`` -> every station + listener counts
* ``https://somafm.com/<station>.pls`` -> tiny PLS playlists (resolved to ice URLs)
* ``https://somafm.com/songs/<station>.json`` -> the "last played" song history
All parsing is deliberately forgiving: SomaFM has changed field names before
and we would rather show a slightly degraded station than crash.
"""
from __future__ import annotations
import asyncio
import re
import time
from collections.abc import Iterable
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from urllib.parse import quote
import httpx
from . import config
CHANNELS_URL = "https://somafm.com/channels.json"
SONGS_URL = "https://somafm.com/songs/{station}.json"
USER_AGENT = "somafm-tui/0.1 (terminal radio; +https://somafm.com)"
REQUEST_TIMEOUT = httpx.Timeout(15.0, connect=8.0)
PLAYLIST_TTL = 8 * 3600
_QUALITY_RANK = {"highest": 0, "high": 1, "low": 2}
_FORMAT_RANK = {"mp3": 0, "aac": 1, "aacp": 2}
_PLAYLIST_CACHE: dict[str, dict[str, Any]] | None = None
def make_client() -> httpx.AsyncClient:
return httpx.AsyncClient(
headers={"User-Agent": USER_AGENT, "Accept": "application/json, */*"},
timeout=REQUEST_TIMEOUT,
follow_redirects=True,
)
# --------------------------------------------------------------------------- #
# models
# --------------------------------------------------------------------------- #
@dataclass(slots=True)
class Song:
title: str = ""
artist: str = ""
album: str = ""
art_url: str = ""
bandcamp_url: str = ""
played_at: datetime | None = None
@property
def display_title(self) -> str:
return self.title.strip() or "Unknown track"
@property
def display_artist(self) -> str:
return self.artist.strip() or "Unknown artist"
def key(self) -> str:
return f"{self.artist}\x1f{self.title}\x1f{self.album}"
@dataclass(slots=True)
class Station:
id: str
title: str
description: str = ""
genre: str = ""
dj: str = ""
image: str = ""
listeners: int = 0
last_playing: str = ""
playlists: list[dict[str, str]] = field(default_factory=list)
now_playing: Song | None = None
history: list[Song] = field(default_factory=list)
favorite: bool = False
@property
def genre_label(self) -> str:
return self.genre.replace("|", " · ") or "eclectic"
def sorted_playlists(self) -> list[dict[str, str]]:
return sorted(
self.playlists,
key=lambda pl: (
_QUALITY_RANK.get(pl.get("quality", ""), 3),
_FORMAT_RANK.get(pl.get("format", ""), 3),
),
)
@property
def format_label(self) -> str:
playlists = self.sorted_playlists()
if not playlists:
return "stream"
first = playlists[0]
fmt = {"aacp": "aac+", "aac": "aac", "mp3": "mp3"}.get(
first.get("format", ""), first.get("format") or "stream"
)
return f"{fmt} · {_guess_bitrate(first.get('url', ''))}k"
def synthetic_mirrors(self) -> list[str]:
"""Direct icecast URLs, handy when the PLS files are unreachable."""
urls: list[str] = []
for suffix in ("-128-mp3", "-64-aac"):
for host in ("ice1", "ice2", "ice3", "ice4"):
urls.append(f"https://{host}.somafm.com/{self.id}{suffix}")
return urls
# --------------------------------------------------------------------------- #
# small helpers
# --------------------------------------------------------------------------- #
def _guess_bitrate(url: str) -> int:
match = re.search(
r"(256|192|130|128|96|64|48|32)(?=\.pls|\.m3u|-mp3|-aac)", url
)
if not match:
return 128
value = int(match.group(1))
return 128 if value == 130 else value
def _to_int(value: Any, default: int = 0) -> int:
if isinstance(value, bool):
return default
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
if isinstance(value, str):
digits = re.sub(r"[^0-9]", "", value)
if digits:
try:
return int(digits)
except ValueError:
return default
return default
def parse_date(value: Any) -> datetime | None:
if value is None or value == "":
return None
if isinstance(value, (int, float)):
try:
return datetime.fromtimestamp(float(value), tz=timezone.utc)
except (OverflowError, OSError, ValueError):
return None
if isinstance(value, str):
text = value.strip()
if text.isdigit():
try:
return datetime.fromtimestamp(int(text), tz=timezone.utc)
except (OverflowError, OSError, ValueError):
return None
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
parsed = None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y/%m/%d %H:%M:%S"):
try:
parsed = datetime.strptime(text, fmt)
break
except ValueError:
continue
if parsed is not None:
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
return None
def parse_last_playing(text: str | None) -> Song | None:
if not isinstance(text, str):
return None
cleaned = text.strip()
if not cleaned:
return None
for separator in (" - ", " – ", " — ", " -- "):
if separator in cleaned:
artist, title = cleaned.split(separator, 1)
return Song(title=title.strip(), artist=artist.strip())
return Song(title=cleaned)
# --------------------------------------------------------------------------- #
# channels
# --------------------------------------------------------------------------- #
def station_from_payload(item: dict[str, Any]) -> Station | None:
if not isinstance(item, dict):
return None
channel_id = item.get("id")
if not isinstance(channel_id, str) or not channel_id.strip():
return None
channel_id = channel_id.strip()
playlists: list[dict[str, str]] = []
raw_playlists = item.get("playlists")
if isinstance(raw_playlists, list):
for entry in raw_playlists:
if not isinstance(entry, dict):
continue
url = entry.get("url")
if not isinstance(url, str) or not url.startswith(("http://", "https://")):
continue
playlists.append(
{
"url": url,
"format": str(entry.get("format") or "").strip().lower(),
"quality": str(entry.get("quality") or "").strip().lower(),
}
)
station = Station(
id=channel_id,
title=str(item.get("title") or channel_id).strip(),
description=str(item.get("description") or "").strip(),
genre=str(item.get("genre") or "").strip(),
dj=str(item.get("dj") or "").strip(),
image=str(item.get("image") or item.get("largeimage") or "").strip(),
listeners=_to_int(item.get("listeners")),
playlists=playlists,
)
last_playing = item.get("lastPlaying")
if isinstance(last_playing, str):
station.last_playing = last_playing.strip()
station.now_playing = parse_last_playing(station.last_playing)
if not station.image:
station.image = f"https://somafm.com/logos/256/{channel_id}256.png"
return station
def parse_channels(payload: Any) -> list[Station]:
if isinstance(payload, dict):
items = payload.get("channels")
elif isinstance(payload, list):
items = payload
else:
items = None
if not isinstance(items, list):
return []
stations: list[Station] = []
seen: set[str] = set()
for item in items:
station = station_from_payload(item)
if station is None or station.id in seen:
continue
seen.add(station.id)
stations.append(station)
return stations
async def fetch_channels(client: httpx.AsyncClient) -> list[Station]:
payload: Any = None
last_error: Exception | None = None
for attempt in range(2):
try:
response = await client.get(CHANNELS_URL)
response.raise_for_status()
payload = response.json()
break
except (httpx.HTTPError, ValueError) as exc: # includes JSON errors
last_error = exc
if attempt == 0:
await asyncio.sleep(0.6)
if payload is None:
raise RuntimeError(f"could not fetch SomaFM channels: {last_error}")
stations = parse_channels(payload)
if not stations:
raise RuntimeError("SomaFM returned an empty channel list")
config.write_json(config.CHANNELS_CACHE, payload)
return stations
def load_cached_channels() -> list[Station]:
return parse_channels(config.read_json(config.CHANNELS_CACHE))
# --------------------------------------------------------------------------- #
# song history
# --------------------------------------------------------------------------- #
def parse_songs(payload: Any) -> list[Song]:
if isinstance(payload, dict):
items = payload.get("songs")
elif isinstance(payload, list):
items = payload
else:
items = None
if not isinstance(items, list):
return []
songs: list[Song] = []
for entry in items:
if not isinstance(entry, dict):
continue
song = Song(
title=str(entry.get("title") or "").strip(),
artist=str(entry.get("artist") or "").strip(),
album=str(entry.get("album") or "").strip(),
art_url=str(
entry.get("albumArt") or entry.get("albumart") or entry.get("art") or ""
).strip(),
played_at=parse_date(entry.get("date")),
)
if song.title or song.artist:
songs.append(song)
# Newest first; the upstream feed is already sorted but let's be sure.
if all(song.played_at for song in songs):
songs.sort(key=lambda song: song.played_at, reverse=True) # type: ignore[arg-type,return-value]
return songs
def _songs_cache_path(station_id: str):
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", station_id)
return config.SONGS_CACHE_DIR / f"{safe}.json"
def load_cached_songs(station_id: str) -> list[Song]:
raw = config.read_json(_songs_cache_path(station_id))
if isinstance(raw, dict):
return parse_songs(raw.get("songs"))
return []
def _songs_to_cache(songs: Iterable[Song]) -> list[dict[str, Any]]:
return [
{
"title": song.title,
"artist": song.artist,
"album": song.album,
"albumArt": song.art_url,
"date": song.played_at.isoformat() if song.played_at else "",
}
for song in songs
]
async def fetch_history(client: httpx.AsyncClient, station_id: str) -> list[Song]:
url = SONGS_URL.format(station=quote(station_id, safe=""))
try:
response = await client.get(url)
response.raise_for_status()
payload = response.json()
except (httpx.HTTPError, ValueError):
return load_cached_songs(station_id)
songs = parse_songs(payload)
if songs:
config.write_json(
_songs_cache_path(station_id),
{"fetched": time.time(), "songs": _songs_to_cache(songs)},
)
return songs
return load_cached_songs(station_id)
# --------------------------------------------------------------------------- #
# playlist resolution
# --------------------------------------------------------------------------- #
def is_playlist(url: str) -> bool:
path = url.split("?", 1)[0].split("#", 1)[0].lower()
return path.endswith((".pls", ".m3u", ".m3u8"))
def is_direct_stream(url: str) -> bool:
return url.startswith(("http://", "https://")) and not is_playlist(url)
def parse_pls(text: str) -> list[str]:
urls: list[str] = []
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
match = re.match(r"(?i)^file\d+\s*=\s*(\S+)$", line)
candidate = match.group(1) if match else line
if candidate.startswith(("http://", "https://")) and candidate not in urls:
urls.append(candidate)
return urls
def _playlist_cache() -> dict[str, dict[str, Any]]:
global _PLAYLIST_CACHE
if _PLAYLIST_CACHE is None:
raw = config.read_json(config.PLAYLIST_CACHE)
_PLAYLIST_CACHE = raw if isinstance(raw, dict) else {}
return _PLAYLIST_CACHE
async def resolve_playlist(client: httpx.AsyncClient, url: str) -> list[str]:
"""Turn a ``.pls`` URL into direct icecast URLs (cached for a few hours)."""
if not url:
return []
if not is_playlist(url):
return [url]
cache = _playlist_cache()
now = time.time()
entry = cache.get(url)
if isinstance(entry, dict):
fetched = entry.get("ts")
urls = entry.get("urls")
if (
isinstance(fetched, (int, float))
and now - fetched < PLAYLIST_TTL
and isinstance(urls, list)
):
cached_urls = [item for item in urls if isinstance(item, str)]
if cached_urls:
return cached_urls
try:
response = await client.get(url, headers={"Accept": "*/*"})
response.raise_for_status()
text = response.text
except httpx.HTTPError:
return []
urls = parse_pls(text)
if urls:
cache[url] = {"ts": now, "urls": urls}
config.write_json(config.PLAYLIST_CACHE, cache)
return urls
async def stream_candidates(client: httpx.AsyncClient, station: Station) -> list[str]:
"""Direct URLs first, then unresolved playlists, then guessed mirrors."""
playlists = station.sorted_playlists()
urls: list[str] = []
for entry in playlists[:3]:
resolved = await resolve_playlist(client, entry["url"])
for url in resolved:
if url not in urls:
urls.append(url)
if not resolved and entry["url"] not in urls:
urls.append(entry["url"])
for entry in playlists[3:]:
if entry["url"] not in urls:
urls.append(entry["url"])
for url in station.synthetic_mirrors():
if url not in urls:
urls.append(url)
return urls[:10]
-1
View File
@@ -1 +0,0 @@
"""Custom Textual widgets for somafm-tui."""
-202
View File
@@ -1,202 +0,0 @@
"""The flashy bit: an ANSI-colour spectrum analyser / oscilloscope."""
from __future__ import annotations
from collections.abc import Sequence
from typing import TYPE_CHECKING, cast
from rich.console import Group, RenderableType
from rich.text import Text
from textual.widget import Widget
from ..spectrum import Frame, SpectrumEngine
from .common import LEVELS, SPECTRUM_STOPS, gradient
if TYPE_CHECKING:
from ..app import SomaFMApp
class SpectrumPanel(Widget):
can_focus = False
def __init__(self, engine: SpectrumEngine) -> None:
super().__init__()
self.engine = engine
self.border_title = "SPECTRUM"
self._palettes: dict[tuple[int, int, bool], list[str]] = {}
# Called from the app ticker so the border title tracks the analyser mode.
def sync(self, mode: str, status: str) -> None:
label = {"both": "MIRROR", "bars": "BARS", "wave": "WAVE"}.get(
mode, mode.upper()
)
title = f"SPECTRUM · {label} · {status}"
if self.border_title != title:
self.border_title = title
self.refresh()
def render(self) -> RenderableType:
app = cast("SomaFMApp", self.app)
frame = self.engine.snapshot()
width = max(8, self.size.width)
height = max(2, self.size.height)
mode = getattr(app, "visualizer", "both")
phase = int(getattr(app, "tick_count", 0) // 2)
dim = frame.status == "idle"
lines: list[Text]
if mode == "wave":
lines = self._wave(frame, width, height, phase, dim)
elif mode == "bars" or height < 8:
lines = self._bars(frame, width, height, phase, dim)
else:
# Default mode: a full-height mirrored spectrum. The top half
# grows up from the centre line, the bottom half grows down from
# it (same values, flipped rows, dimmed like a reflection).
top_height = height // 2
bottom_height = height - top_height
lines = self._bars(frame, width, top_height, phase, dim)
mirrored = self._bars(
frame, width, bottom_height, phase + 5, True, peaks=False
)
mirrored.reverse()
lines.extend(mirrored)
return Group(*lines)
# ------------------------------------------------------------------ #
# drawing
# ------------------------------------------------------------------ #
def _row_palette(self, height: int, phase: int, dim: bool) -> list[str]:
key = (height, phase % 12, dim)
cached = self._palettes.get(key)
if cached is not None:
return cached
palette: list[str] = []
for row in range(height):
position = (height - 1 - row) / max(1, height - 1)
colour = gradient(SPECTRUM_STOPS, position)
style = f"bold {colour}" if (row + phase) % 7 == 0 else colour
if dim:
style = f"dim {style}"
palette.append(style)
if len(self._palettes) > 96:
self._palettes.clear()
self._palettes[key] = palette
return palette
@staticmethod
def _resample(values: Sequence[float], width: int) -> list[float]:
count = len(values)
if count == 0:
return [0.0] * width
if width <= 1 or count == 1:
return [float(values[0])] * width
last = count - 1
output: list[float] = []
for column in range(width):
position = column * last / (width - 1)
low = int(position)
high = min(low + 1, last)
fraction = position - low
output.append(values[low] * (1.0 - fraction) + values[high] * fraction)
return output
@staticmethod
def _resample_mean(values: Sequence[float], width: int) -> list[float]:
count = len(values)
if count == 0:
return [0.0] * width
output: list[float] = []
for column in range(width):
low = int(column * count / width)
high = max(low + 1, int((column + 1) * count / width))
high = min(high, count)
chunk = values[low:high]
output.append(sum(chunk) / len(chunk) if chunk else 0.0)
return output
def _bars(
self,
frame: Frame,
width: int,
height: int,
phase: int,
dim: bool,
peaks: bool = True,
) -> list[Text]:
values = self._resample(frame.bands, width)
peaks = self._resample(frame.peaks, width)
palette = self._row_palette(height, phase, dim)
scale = height * 8.0
lines: list[Text] = []
for row in range(height):
line = Text(no_wrap=True)
row_top = (height - row - 1) * 8.0
style = palette[row]
for column in range(width):
units = min(values[column], 1.0) * scale - row_top
peak_units = min(peaks[column], 1.0) * scale - row_top
if units >= 8.0:
line.append("█", style=style)
elif units > 0.0:
level = max(1, min(7, int(units)))
line.append(LEVELS[level], style=style)
elif (
peaks
and 0.0 <= peak_units < 8.0
and peak_units > units + 1.0
):
line.append("▁", style="dim" if dim else "bold")
else:
line.append(" ", style="")
lines.append(line)
return lines
def _wave(
self, frame: Frame, width: int, height: int, phase: int, dim: bool
) -> list[Text]:
wave = self._resample_mean(frame.wave, width)
# The engine normalises each chunk, but smoothing and averaging make
# the trace sit around the middle. A gentle display gain lets the
# oscilloscope use the full height without amplifying silence into
# noise.
peak = max((abs(value) for value in wave), default=0.0)
if peak > 0.02:
gain = min(2.5, 0.9 / peak)
wave = [value * gain for value in wave]
palette = self._row_palette(height, phase, dim)
centre = (height - 1) / 2.0
amplitude = max(0.75, (height - 1) / 2.0)
grid = [[" " for _ in range(width)] for _ in range(height)]
previous_row: int | None = None
for column, value in enumerate(wave):
clamped = max(-1.0, min(1.0, float(value)))
position = centre - clamped * amplitude
row = max(0, min(height - 1, round(position)))
grid[row][column] = "█"
delta = position - row
if delta > 0.35 and row + 1 < height:
grid[row + 1][column] = "▄"
elif delta < -0.35 and row - 1 >= 0:
grid[row - 1][column] = "▀"
if previous_row is not None and previous_row != row:
low, high = sorted((previous_row, row))
for between in range(low + 1, high):
if grid[between][column] == " ":
grid[between][column] = "│"
previous_row = row
lines: list[Text] = []
for row in range(height):
line = Text(no_wrap=True)
style = palette[row]
for char in grid[row]:
if char == " ":
line.append(" ", style="")
else:
line.append(char, style=style)
lines.append(line)
return lines
+6 -3
View File
@@ -2,11 +2,12 @@ from __future__ import annotations
import os import os
os.environ["SOMAFM_IMAGES"] = "0" os.environ["BEEFY_IMAGES"] = "0"
import pytest import pytest
from somafm_tui import api, config from beefy_player import config
from beefy_player.providers import base
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -25,6 +26,8 @@ def isolated_state(tmp_path, monkeypatch):
("CONFIG_PATH", config_dir / "config.json"), ("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"),
("ARTWORK_CACHE_DIR", cache_dir / "artwork"),
("LEGACY_CONFIG_PATH", tmp_path / "legacy" / "config.json"),
): ):
monkeypatch.setattr(config, attr, value) monkeypatch.setattr(config, attr, value)
@@ -33,6 +36,6 @@ def isolated_state(tmp_path, monkeypatch):
path.mkdir(parents=True, exist_ok=True) path.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(config, "ensure_dirs", ensure_dirs) monkeypatch.setattr(config, "ensure_dirs", ensure_dirs)
monkeypatch.setattr(api, "_PLAYLIST_CACHE", None) base.reset_playlist_cache()
ensure_dirs() ensure_dirs()
yield yield
+18 -13
View File
@@ -3,7 +3,12 @@ from __future__ import annotations
import httpx import httpx
import pytest import pytest
from somafm_tui import api, config from beefy_player import config
from beefy_player.models import parse_last_playing
from beefy_player.providers import base, get_provider
from beefy_player.providers.somafm import api
SOMAFM = get_provider("somafm")
CHANNELS_PAYLOAD = { CHANNELS_PAYLOAD = {
"channels": [ "channels": [
@@ -50,14 +55,14 @@ def test_parse_channels() -> None:
def test_parse_last_playing() -> None: def test_parse_last_playing() -> None:
song = api.parse_last_playing("Jens Buchert - Rauschebart") song = parse_last_playing("Jens Buchert - Rauschebart")
assert song is not None and song.artist == "Jens Buchert" and song.title == "Rauschebart" assert song is not None and song.artist == "Jens Buchert" and song.title == "Rauschebart"
en_dash = api.parse_last_playing("Tosca – Amber November") en_dash = parse_last_playing("Tosca – Amber November")
assert en_dash is not None and en_dash.artist == "Tosca" assert en_dash is not None and en_dash.artist == "Tosca"
plain = api.parse_last_playing("Just A Title") plain = parse_last_playing("Just A Title")
assert plain is not None and plain.title == "Just A Title" and plain.artist == "" assert plain is not None and plain.title == "Just A Title" and plain.artist == ""
assert api.parse_last_playing("") is None assert parse_last_playing("") is None
assert api.parse_last_playing(None) is None assert parse_last_playing(None) is None
def test_parse_pls() -> None: def test_parse_pls() -> None:
@@ -67,16 +72,16 @@ File1=https://ice2.somafm.com/groovesalad-256-mp3
File2=https://ice6.somafm.com/groovesalad-256-mp3 File2=https://ice6.somafm.com/groovesalad-256-mp3
Version=2 Version=2
""" """
assert api.parse_pls(text) == [ assert base.parse_pls(text) == [
"https://ice2.somafm.com/groovesalad-256-mp3", "https://ice2.somafm.com/groovesalad-256-mp3",
"https://ice6.somafm.com/groovesalad-256-mp3", "https://ice6.somafm.com/groovesalad-256-mp3",
] ]
assert api.parse_pls("https://ice1.somafm.com/x-128-mp3\n# comment\n") == [ assert base.parse_pls("https://ice1.somafm.com/x-128-mp3\n# comment\n") == [
"https://ice1.somafm.com/x-128-mp3" "https://ice1.somafm.com/x-128-mp3"
] ]
assert api.is_playlist("https://x/y.pls?foo=1") is True assert base.is_playlist("https://x/y.pls?foo=1") is True
assert api.is_direct_stream("https://ice1.somafm.com/x-128-mp3") is True assert base.is_direct_stream("https://ice1.somafm.com/x-128-mp3") is True
assert api.is_direct_stream("https://x/y.m3u") is False assert base.is_direct_stream("https://x/y.m3u") is False
def test_parse_songs_and_dates() -> None: def test_parse_songs_and_dates() -> None:
@@ -143,7 +148,7 @@ async def test_stream_candidates_with_mock_transport() -> None:
station = api.parse_channels(CHANNELS_PAYLOAD)[0] station = api.parse_channels(CHANNELS_PAYLOAD)[0]
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
urls = await api.stream_candidates(client, station) urls = await SOMAFM.stream_candidates(client, station)
assert urls[:2] == [ assert urls[:2] == [
"https://ice2.somafm.com/x-256-mp3", "https://ice2.somafm.com/x-256-mp3",
@@ -152,7 +157,7 @@ async def test_stream_candidates_with_mock_transport() -> None:
assert len(requests) == 3 # three playlist URLs, cached by URL assert len(requests) == 3 # three playlist URLs, cached by URL
# second run hits the playlist cache # second run hits the playlist cache
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
await api.stream_candidates(client, station) await SOMAFM.stream_candidates(client, station)
assert len(requests) == 3 assert len(requests) == 3
+95 -13
View File
@@ -6,21 +6,31 @@ from datetime import datetime, timedelta, timezone
import pytest import pytest
from somafm_tui.api import Song from beefy_player import config
from somafm_tui.app import SomaFMApp from beefy_player.app import BeefyPlayerApp
from somafm_tui.widgets.help import HelpScreen from beefy_player.models import Song
from somafm_tui.widgets.stage import StagePanel from beefy_player.widgets.help import HelpScreen
from somafm_tui.widgets.station_list import StationList from beefy_player.widgets.stage import StagePanel
from beefy_player.widgets.station_list import StationList
def rendered_text(app: SomaFMApp) -> str: def _history_method(func):
"""Adapt a ``(client, station_id)`` fake to the provider method signature."""
async def method(self, client, station_id):
return await func(client, station_id)
return method
def rendered_text(app: BeefyPlayerApp) -> str:
svg = html.unescape(app.export_screenshot()).replace("\xa0", " ") svg = html.unescape(app.export_screenshot()).replace("\xa0", " ")
return re.sub(r"<[^>]+>", " ", svg) return re.sub(r"<[^>]+>", " ", svg)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_boot_navigation_filter_favourite_visualizer_help() -> None: async def test_boot_navigation_filter_favourite_visualizer_help() -> None:
app = SomaFMApp(autoplay=False, offline=True, audio=False) app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(110, 36)) as pilot: async with app.run_test(size=(110, 36)) as pilot:
await pilot.pause() await pilot.pause()
@@ -70,11 +80,11 @@ async def test_boot_navigation_filter_favourite_visualizer_help() -> None:
assert app.index == 0 assert app.index == 0
modes = set() modes = set()
for _ in range(3): for _ in range(len(config.VISUALIZERS)):
await pilot.press("v") await pilot.press("v")
await pilot.pause() await pilot.pause()
modes.add(app.visualizer) modes.add(app.visualizer)
assert modes == {"both", "bars", "wave"} assert modes == set(config.VISUALIZERS)
await pilot.press("question_mark") await pilot.press("question_mark")
await pilot.pause() await pilot.pause()
@@ -91,7 +101,7 @@ async def test_boot_navigation_filter_favourite_visualizer_help() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stage_renders_history_and_listener_count() -> None: async def test_stage_renders_history_and_listener_count() -> None:
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()
station = app.current station = app.current
@@ -122,9 +132,22 @@ async def test_stage_renders_history_and_listener_count() -> None:
assert needle in text assert needle in text
@pytest.mark.asyncio
async def test_splash_names_the_provider() -> None:
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(100, 32)) as pilot:
await pilot.pause()
app.current = None
app.query_one(StagePanel).refresh_stage()
await pilot.pause()
text = rendered_text(app)
assert "SOMA FM" in text
assert "somafm.com/channels.json" in text
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_narrow_layout() -> None: async def test_narrow_layout() -> None:
app = SomaFMApp(autoplay=False, offline=True, audio=False) app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(64, 20)) as pilot: async with app.run_test(size=(64, 20)) as pilot:
await pilot.pause() await pilot.pause()
app.query_one(StagePanel).refresh_stage() app.query_one(StagePanel).refresh_stage()
@@ -137,7 +160,7 @@ async def test_narrow_layout() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stage_shows_bandcamp_link_only_when_present() -> None: async def test_stage_shows_bandcamp_link_only_when_present() -> None:
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()
station = app.current station = app.current
@@ -159,9 +182,68 @@ async def test_stage_shows_bandcamp_link_only_when_present() -> None:
assert "Bandcamp" not in rendered_text(app) assert "Bandcamp" not in rendered_text(app)
@pytest.mark.asyncio
async def test_bandcamp_link_survives_history_refresh(monkeypatch) -> None:
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(110, 40)) as pilot:
await pilot.pause()
station = app.current
assert station is not None
url = "https://jensbuchert.bandcamp.com/track/rauschebart"
old_song = Song(
title="Rauschebart",
artist="Jens Buchert",
album="Gravity",
bandcamp_url=url,
)
station.now_playing = old_song
station.history = [old_song]
async def fake_fetch_history(client, station_id):
assert station_id == station.id
return [
Song(
title="Rauschebart",
artist="Jens Buchert",
album="Gravity",
)
]
monkeypatch.setattr(
type(app.provider), "fetch_history", _history_method(fake_fetch_history)
)
worker = app._fetch_history(station.id)
await worker.wait()
await pilot.pause()
assert station.now_playing is not None
assert station.now_playing is not old_song
assert station.now_playing.bandcamp_url == url
assert "Bandcamp" in rendered_text(app)
@pytest.mark.asyncio
async def test_cached_bandcamp_link_is_reapplied_to_new_song() -> None:
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(110, 40)) as pilot:
await pilot.pause()
station = app.current
assert station is not None
url = "https://jensbuchert.bandcamp.com/track/rauschebart"
song = Song(title="Rauschebart", artist="Jens Buchert", album="Gravity")
app._bandcamp_urls[f"{station.id}|{song.key()}"] = url
station.now_playing = song
app._update_bandcamp()
await pilot.pause()
assert song.bandcamp_url == url
assert "Bandcamp" in rendered_text(app)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_media_session_commands_drive_the_app() -> None: async def test_media_session_commands_drive_the_app() -> 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()
# The status bar names whichever backend this platform uses. # The status bar names whichever backend this platform uses.
+6 -6
View File
@@ -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 -4
View File
@@ -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:
+74 -18
View File
@@ -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": {
@@ -151,26 +151,60 @@ def test_pick_track_and_album_ignore_mismatches() -> None:
assert bandcamp.pick_album(results, "Someone Else", "Gravity") is None assert bandcamp.pick_album(results, "Someone Else", "Gravity") is None
def test_build_query_normalises_for_autocomplete() -> None:
assert bandcamp.build_query(
" Diskette Park ", "Dreams — [Extended Mix] (Live)!"
) == "diskette park dreams extended mix live!"
assert bandcamp.build_query("Don't Stop", "AC/DC") == "dont stop acdc"
def test_build_prefixes_grows_in_small_typing_groups() -> None:
assert bandcamp.build_prefixes("diskette park dreams") == [
"dis",
"disket",
"diskette ",
"diskette par",
"diskette park d",
"diskette park dreams",
]
assert bandcamp.build_prefixes("abcde") == ["abcde"]
assert bandcamp.build_prefixes("") == []
def test_matches_is_case_and_whitespace_insensitive() -> None:
assert bandcamp._matches("Jens Buchert", "jens buchert")
assert bandcamp._matches("Rauschebart", "RAUSCHEBART (Live)")
assert not bandcamp._matches("Dreams", "Something Else")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_find_track_uses_track_search_first() -> None: async def test_find_track_uses_track_search_first() -> None:
requests: list[httpx.Request] = [] requests: list[httpx.Request] = []
match_text = "jens buchert ra"
def handler(request: httpx.Request) -> httpx.Response: def handler(request: httpx.Request) -> httpx.Response:
requests.append(request) requests.append(request)
assert request.method == "POST" assert request.method == "POST"
assert json.loads(request.content) == { body = json.loads(request.content)
"search_text": "Jens Buchert Rauschebart", assert body["search_filter"] == "t"
"search_filter": "t", assert body["full_page"] is True
"full_page": True, assert body["fan_id"] is None
"fan_id": None, payload = TRACK_PAYLOAD if body["search_text"] == match_text else {
"auto": {"results": []}
} }
return httpx.Response(200, json=TRACK_PAYLOAD) return httpx.Response(200, json=payload)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
url = await bandcamp.find_track(client, "Jens Buchert", "Gravity", "Rauschebart") url = await bandcamp.find_track(client, "Jens Buchert", "Gravity", "Rauschebart")
assert url == "https://jensbuchert.bandcamp.com/track/rauschebart" assert url == "https://jensbuchert.bandcamp.com/track/rauschebart"
assert len(requests) == 1 assert [json.loads(request.content)["search_text"] for request in requests] == [
"jen",
"jens b",
"jens buch",
"jens buchert",
"jens buchert ra",
]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -184,7 +218,12 @@ async def test_find_track_falls_back_to_album_then_track_on_page() -> None:
if body["search_filter"] == "t": if body["search_filter"] == "t":
return httpx.Response(200, json={"auto": {"results": []}}) return httpx.Response(200, json={"auto": {"results": []}})
if body["search_filter"] == "a": if body["search_filter"] == "a":
return httpx.Response(200, json=ALBUM_PAYLOAD) payload = (
ALBUM_PAYLOAD
if body["search_text"] == "jens buchert"
else {"auto": {"results": []}}
)
return httpx.Response(200, json=payload)
if request.url.path == "/album/gravity": if request.url.path == "/album/gravity":
return httpx.Response(200, text=ALBUM_PAGE_HTML) return httpx.Response(200, text=ALBUM_PAGE_HTML)
return httpx.Response(404) return httpx.Response(404)
@@ -195,15 +234,30 @@ async def test_find_track_falls_back_to_album_then_track_on_page() -> None:
) )
assert url == "https://jensbuchert.bandcamp.com/track/rauschebart" assert url == "https://jensbuchert.bandcamp.com/track/rauschebart"
assert len(requests) == 3 search_bodies = [
search_bodies = [json.loads(request.content) for request in requests[:2]] json.loads(request.content)
assert [body["search_text"] for body in search_bodies] == [ for request in requests
"Jens Buchert Rauschebart", if request.method == "POST"
"Jens Buchert Gravity",
] ]
assert [body["search_filter"] for body in search_bodies] == ["t", "a"] assert [body["search_text"] for body in search_bodies] == [
assert requests[2].method == "GET" "jen",
assert requests[2].url.path == "/album/gravity" "jens b",
"jens buch",
"jens buchert",
"jens buchert ra",
"jens buchert rausc",
"jens buchert rauscheb",
"jens buchert rauschebart",
"jen",
"jens b",
"jens buch",
"jens buchert",
]
assert [body["search_filter"] for body in search_bodies] == ["t"] * 8 + [
"a"
] * 4
assert requests[-1].method == "GET"
assert requests[-1].url.path == "/album/gravity"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -216,4 +270,6 @@ async def test_find_track_returns_none_for_challenge_or_no_results() -> None:
async with httpx.AsyncClient(transport=httpx.MockTransport(challenge)) as client: async with httpx.AsyncClient(transport=httpx.MockTransport(challenge)) as client:
assert await bandcamp.find_track(client, "A", "B", "C") is None assert await bandcamp.find_track(client, "A", "B", "C") is None
assert len(requests) == 2 assert len(requests) == len(bandcamp.build_prefixes("a c")) + len(
bandcamp.build_prefixes("a b")
)
+1 -1
View File
@@ -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}$")
+1 -1
View File
@@ -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:
+11 -1
View File
@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
from somafm_tui.__main__ import _build_parser, _list_stations import pytest
from beefy_player.__main__ import _build_parser, _list_stations
def test_parser_defaults_and_flags() -> None: def test_parser_defaults_and_flags() -> None:
@@ -19,6 +21,14 @@ def test_parser_defaults_and_flags() -> None:
assert args.autoplay is True assert args.autoplay is True
assert args.no_mpris is True assert args.no_mpris is True
assert args.spectrum_source == "monitor" assert args.spectrum_source == "monitor"
assert args.provider is None # the config file decides
def test_provider_flag_is_validated() -> None:
parser = _build_parser()
assert parser.parse_args(["--provider", "somafm"]).provider == "somafm"
with pytest.raises(SystemExit):
parser.parse_args(["--provider", "no-such-catalogue"])
def test_list_stations_offline(capsys) -> None: def test_list_stations_offline(capsys) -> None:
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+130
View File
@@ -0,0 +1,130 @@
from __future__ import annotations
import html
import json
import re
import pytest
from beefy_player import config
from beefy_player.models import Song, Station
from beefy_player.providers import PROVIDERS, available_providers, get_provider
from beefy_player.providers.base import RadioProvider
from beefy_player.providers.somafm import SomaFMProvider
def rendered_text(app) -> str:
svg = html.unescape(app.export_screenshot()).replace("\xa0", " ")
return re.sub(r"<[^>]+>", " ", svg)
def test_registry_lists_somafm_as_the_default() -> None:
assert available_providers() == ("somafm",)
assert isinstance(get_provider(), SomaFMProvider)
assert get_provider("somafm") is get_provider()
# a stale slug in the config file must not stop the app from starting
assert get_provider("no-such-catalogue") is get_provider()
def test_somafm_provider_describes_its_catalogue() -> None:
provider = get_provider()
assert provider.name == "SomaFM"
assert provider.brand == "SOMA FM"
assert provider.station_source == "somafm.com/channels.json"
assert "somafm.com" in provider.user_agent
stations = provider.fallback_stations()
assert len(stations) > 40
station = stations[0]
assert station.provider == "somafm"
assert station.uid == f"somafm:{station.id}"
assert all("somafm.com" in url for url in provider.fallback_urls(station))
class FakeProvider(RadioProvider):
"""A second catalogue, as small as the interface allows."""
id = "fake"
name = "Fake FM"
homepage = "fake.test"
brand = "FAKE FM"
tagline = "not real radio"
station_source = "fake.test/stations.json"
history_source = "recent.json"
def __init__(self) -> None:
self.station = Station(
id="one", title="Fake One", genre="test", provider=self.id
)
async def fetch_stations(self, client):
return [self.station]
def load_cached_stations(self):
return [self.station]
def fallback_stations(self):
return [self.station]
async def fetch_history(self, client, station_id):
return [Song(title="Tune", artist="Someone")]
def load_cached_history(self, station_id):
return []
@pytest.mark.asyncio
async def test_app_runs_against_a_different_catalogue(monkeypatch) -> None:
"""The UI must not know which catalogue it is showing."""
from beefy_player.app import BeefyPlayerApp
fake = FakeProvider()
monkeypatch.setitem(PROVIDERS, "fake", fake)
app = BeefyPlayerApp(provider="fake", autoplay=False, offline=True, audio=False)
assert app.provider is fake
async with app.run_test(size=(100, 30)) as pilot:
await pilot.pause()
assert app.current is not None
assert app.current.uid == "fake:one"
app.query_one("StagePanel").refresh_stage()
await pilot.pause()
text = rendered_text(app)
assert "FAKE ONE" in text
assert "Fake FM" in app.title
def test_config_remembers_the_provider() -> None:
assert config.Config.load().provider == "somafm"
config.Config(provider="fake").save()
assert config.Config.load().provider == "fake"
def test_legacy_config_is_migrated_once(monkeypatch) -> None:
legacy = config.LEGACY_CONFIG_PATH
legacy.parent.mkdir(parents=True, exist_ok=True)
legacy.write_text(
json.dumps({"favorites": ["defcon"], "volume": 33, "last_station": "dronezone"})
)
loaded = config.Config.load()
assert loaded.favorites == ["defcon"]
assert loaded.volume == 33
assert loaded.last_station == "dronezone"
assert config.CONFIG_PATH.exists()
# the new file wins from then on
config.Config(volume=11).save()
legacy.write_text(json.dumps({"volume": 99}))
assert config.Config.load().volume == 11
def test_environment_variables_prefer_the_beefy_prefix(monkeypatch) -> None:
monkeypatch.setenv("BEEFY_IMAGES", "tgp")
monkeypatch.setenv("SOMAFM_IMAGES", "0")
assert config.env_var("IMAGES") == "tgp"
monkeypatch.delenv("BEEFY_IMAGES")
assert config.env_var("IMAGES") == "0" # the old spelling still works
monkeypatch.delenv("SOMAFM_IMAGES")
assert config.env_var("IMAGES") == ""
+124 -3
View File
@@ -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,
@@ -324,3 +324,124 @@ def test_dead_monitor_falls_back_instead_of_flat_bars(tmp_path) -> None:
finally: finally:
engine._stop_proc(proc) engine._stop_proc(proc)
engine.stop() engine.stop()
def test_monitor_survives_silence_after_audio(tmp_path, monkeypatch) -> None:
# Tuning a station leaves mpv's sink silent while the new stream buffers.
# That gap must not be mistaken for a dead monitor: only a capture that has
# never delivered audio is written off, otherwise SYNC would be lost (and
# replaced by the drifting stream tap) for the rest of the session.
script = tmp_path / "parec"
script.write_text(
"#!/usr/bin/env python3\n"
"import math, struct, sys, time\n"
"sr = 44100\n"
"n = 2048\n"
"tone = struct.pack('<%dh' % n, *["
"int(0.6 * 32767 * math.sin(2 * math.pi * 1000 * i / sr)) for i in range(n)])\n"
"silence = b'\\0' * (2 * n)\n"
"out = sys.stdout.buffer\n"
"for _ in range(40):\n"
" out.write(tone)\n"
"out.flush()\n"
"for _ in range(400):\n"
" out.write(silence)\n"
"out.flush()\n"
"time.sleep(30)\n"
)
script.chmod(0o755)
def fake_which(name: str) -> str | None:
return str(script) if name == "parec" else None
monkeypatch.setattr(spectrum_module.shutil, "which", fake_which)
engine = SpectrumEngine(ffmpeg_path="")
engine.start()
try:
engine.set_source("https://example.invalid/live")
assert wait_for_spectrum(engine, "monitor").status == "monitor"
# The script now feeds far more than SILENT_MONITOR_BLOCKS of digital
# silence. Give the analyser time to consume it, then make sure the
# capture is still alive and still SYNC.
time.sleep(1.0)
snap = engine.snapshot()
assert snap.status == "monitor", snap
assert snap.label == "SYNC"
assert engine.monitor_available is True
assert engine._monitor_failed is False
finally:
engine.stop()
class SequencedTap(FakeTap):
"""A tap that hands out a fixed list of buffers, then goes quiet."""
def __init__(self, chunks: list[bytes], **kwargs) -> None:
super().__init__(**kwargs)
self._chunks = list(chunks)
def read(self, timeout: float | None = 1.0) -> bytes | None:
if self.stopped:
return None
return self._chunks.pop(0) if self._chunks else None
def test_tap_survives_silence_after_audio(monkeypatch) -> None:
# A real tap that has already captured audio is allowed to go quiet while
# a new station buffers; only a tap that never produced audio means the
# System Audio Recording permission is missing.
engine = SpectrumEngine(ffmpeg_path="")
engine._parec = None
engine._darwin = True
monkeypatch.setattr(spectrum_module.macos_tap, "available", lambda: True)
chunks = [tap_pcm(1000, 4096)] + [b"\x00" * (4096 * 8)] * 200
tap = SequencedTap(chunks)
monkeypatch.setattr(spectrum_module.macos_tap, "SystemAudioTap", lambda: tap)
engine.start()
try:
engine.set_source("https://example.invalid/live")
assert wait_for_spectrum(engine, "monitor").status == "monitor"
time.sleep(0.5)
snap = engine.snapshot()
assert snap.status == "monitor", snap
assert engine.monitor_available is True
assert engine._monitor_failed is False
assert tap.stopped is False
finally:
engine.stop()
def test_tuning_clears_a_monitor_failure_and_retries_sync(tmp_path, monkeypatch) -> None:
# Once an output capture has failed (or been given up on after a tuning
# gap), a station change must clear that state and try SYNC again instead
# of staying on the drifting FFT stream for the rest of the session.
script = tmp_path / "parec"
write_pcm_script(script)
def fake_which(name: str) -> str | None:
return str(script) if name == "parec" else None
monkeypatch.setattr(spectrum_module.shutil, "which", fake_which)
engine = SpectrumEngine(ffmpeg_path="")
engine.start()
try:
engine.set_source("https://example.invalid/a")
assert wait_for_spectrum(engine, "monitor").status == "monitor"
# Simulate the latch that a silent retry used to leave behind.
engine._monitor_failed = True
engine._tap_retry_at = time.monotonic() + 60.0
for index in range(10):
engine.set_source(f"https://example.invalid/b{index}")
time.sleep(0.02)
snap = wait_for_spectrum(engine, "monitor")
assert snap.status == "monitor", snap
assert engine._monitor_failed is False
assert engine.monitor_available is True
assert engine._monitor_worked is True
finally:
engine.stop()
+103
View File
@@ -0,0 +1,103 @@
"""Rendering tests for the waterfall, radial and braille visualisers."""
from __future__ import annotations
import math
from beefy_player import config
from beefy_player.spectrum import Frame, SpectrumEngine
from beefy_player.widgets.spectrum import (
VISUALIZER_LABELS,
WATERFALL_HISTORY,
SpectrumPanel,
)
def make_frame(level: float = 0.7) -> Frame:
bands = tuple(0.12 + 0.8 * max(0.0, math.sin(index / 4.0)) for index in range(72))
peaks = tuple(min(1.0, value + 0.05) for value in bands)
wave = tuple(level * math.sin(point / 9.0) for point in range(192))
return Frame(bands=bands, peaks=peaks, wave=wave, level=level, status="monitor")
def make_panel() -> SpectrumPanel:
return SpectrumPanel(SpectrumEngine())
def test_every_visualizer_has_a_border_label() -> None:
assert set(VISUALIZER_LABELS) == set(config.VISUALIZERS)
def test_waterfall_records_one_column_per_tick() -> None:
panel = make_panel()
frame = make_frame()
panel._record(frame, tick=1)
panel._record(frame, tick=2)
panel._record(frame, tick=2) # a second render in the same tick
assert len(panel._history) == 2
for tick in range(3, WATERFALL_HISTORY + 20):
panel._record(frame, tick)
assert len(panel._history) == WATERFALL_HISTORY
def test_waterfall_scrolls_spectrogram_with_bass_at_the_bottom() -> None:
panel = make_panel()
# Quieter highs, louder bass, so the bottom rows must be fuller.
rising = Frame(
bands=tuple((71 - index) / 71 for index in range(72)),
peaks=tuple((71 - index) / 71 for index in range(72)),
wave=(0.0,) * 192,
level=0.5,
status="monitor",
)
for tick in range(30):
panel._record(rising, tick)
lines = panel._waterfall(40, 12, dim=False)
assert len(lines) == 12
assert {len(line.plain) for line in lines} == {40}
chars = "".join(line.plain for line in lines)
assert any(char in chars for char in "░▒▓█")
# History that does not fill the panel is padded blank on the left.
assert lines[0].plain[:9].strip() == ""
filled = [sum(1 for char in line.plain if char != " ") for line in lines]
assert filled[-1] > filled[0]
def test_radial_spectrum_draws_a_ring_inside_the_panel() -> None:
panel = make_panel()
lines = panel._radial(make_frame(), 61, 21, phase=0, dim=False)
assert len(lines) == 21
assert {len(line.plain) for line in lines} == {61}
text = "".join(line.plain for line in lines)
assert text.count("█") > 10
assert text.count("·") > 4
# The corners are outside the circle.
assert lines[2].plain[:6].strip() == ""
assert lines[2].plain[-6:].strip() == ""
def test_radial_falls_back_to_bars_when_too_small() -> None:
panel = make_panel()
lines = panel._radial(make_frame(), 12, 4, phase=0, dim=False)
assert len(lines) == 4
assert {len(line.plain) for line in lines} == {12}
assert any(char in "".join(line.plain for line in lines) for char in "█▄▅")
def test_braille_oscilloscope_uses_subcell_dots() -> None:
panel = make_panel()
lines = panel._braille(make_frame(0.9), 40, 8, phase=0, dim=False)
assert len(lines) == 8
assert {len(line.plain) for line in lines} == {40}
chars = "".join(line.plain for line in lines)
assert all(char == " " or 0x2800 <= ord(char) <= 0x28FF for char in chars)
dots = [char for char in chars if 0x2800 <= ord(char) <= 0x28FF]
assert len(dots) > 20
def test_braille_keeps_a_baseline_when_silent() -> None:
panel = make_panel()
silent = Frame((0.0,) * 72, (0.0,) * 72, (0.0,) * 192, 0.0, "idle")
lines = panel._braille(silent, 20, 6, phase=0, dim=True)
chars = "".join(line.plain for line in lines)
assert any(0x2800 <= ord(char) <= 0x28FF for char in chars)
+9 -9
View File
@@ -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()
+1 -1
View File
@@ -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")
Generated
+1 -1
View File
@@ -683,7 +683,7 @@ wheels = [
] ]
[[package]] [[package]]
name = "somafm-tui" name = "beefy-player"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [