Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58e9202e25 | ||
|
|
c4cc91ee1a | ||
|
|
e30ca09627 | ||
|
|
4bed90b489 | ||
|
|
a42678ef30 | ||
|
|
6d188c00bc | ||
|
|
b9d3d7e809 | ||
|
|
ca45303386 | ||
|
|
d2b4f1bf64 | ||
|
|
3ae4dc3dab |
No files matched your search
@@ -1,4 +1,4 @@
|
|||||||
Copyright (C) 2026 somafm-tui contributors
|
Copyright (C) 2026 beefy-player contributors
|
||||||
|
|
||||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||||
Version 2, December 2004
|
Version 2, December 2004
|
||||||
|
|||||||
@@ -1,25 +1,33 @@
|
|||||||
# somafm-tui
|
# Beefy Player
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
A flashy terminal radio for **[SomaFM](https://somafm.com)** — a live spectrum
|
A flashy terminal player for **internet radio** — a live spectrum analyser,
|
||||||
analyser, song history, and full desktop media integration, drawn with your
|
song history, and full desktop media integration, drawn with your terminal's
|
||||||
terminal's own ANSI palette on the terminal's default background so it fits
|
own ANSI palette on the terminal's default background so it fits any theme
|
||||||
any theme (including transparent terminals).
|
(including transparent terminals).
|
||||||
|
|
||||||
|
The station catalogue is pluggable. Beefy Player ships with
|
||||||
|
**[SomaFM](https://somafm.com)** (its first and, at the moment, only provider),
|
||||||
|
and the UI never talks to a station catalogue directly: it asks a
|
||||||
|
[provider](#providers) for stations, song history and stream URLs. Adding
|
||||||
|
another internet-radio catalogue is a self-contained module, not a rewrite.
|
||||||
|
|
||||||
It runs the same way on **Linux** (PulseAudio/PipeWire) and **macOS**
|
It runs the same way on **Linux** (PulseAudio/PipeWire) and **macOS**
|
||||||
(CoreAudio, iTerm2/Kitty/WezTerm): MPRIS on the desktop bus where that exists,
|
(CoreAudio, iTerm2/Kitty/WezTerm): MPRIS on the desktop bus where that exists,
|
||||||
macOS Now Playing (Control Center and the media keys) where it does not.
|
macOS Now Playing (Control Center and the media keys) where it does not.
|
||||||
|
|
||||||
No existing SomaFM client is used: the station list, playlists, song history,
|
No existing radio client is used: the station list, playlists, song history,
|
||||||
audio control, spectrum visualiser and media-session integration are all
|
audio control, spectrum visualiser and media-session integration are all
|
||||||
implemented here from the public SomaFM endpoints and open protocols.
|
implemented here from public endpoints and open protocols.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **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.
@@ -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,
|
||||||
@@ -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.
|
||||||
"""
|
"""
|
||||||
@@ -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)
|
||||||
@@ -104,10 +104,12 @@ def build_metadata(
|
|||||||
(track_key or f"{station}|{artist}|{title}").encode("utf-8")
|
(track_key or f"{station}|{artist}|{title}").encode("utf-8")
|
||||||
).hexdigest()[:24]
|
).hexdigest()[:24]
|
||||||
metadata: dict[str, Any] = {
|
metadata: dict[str, Any] = {
|
||||||
"mpris:trackid": Variant("o", f"/org/somafm/track/{digest}"),
|
"mpris:trackid": Variant("o", f"/org/beefyplayer/track/{digest}"),
|
||||||
"xesam:title": Variant("s", title or station or "SomaFM"),
|
"xesam:title": Variant("s", title or station or "Beefy Player"),
|
||||||
"xesam:artist": Variant("as", [artist] if artist else ["SomaFM"]),
|
"xesam:artist": Variant("as", [artist] if artist else ["Beefy Player"]),
|
||||||
"xesam:comment": Variant("as", [f"SomaFM · {station}"] if station else ["SomaFM"]),
|
"xesam:comment": Variant(
|
||||||
|
"as", [f"Beefy Player · {station}"] if station else ["Beefy Player"]
|
||||||
|
),
|
||||||
}
|
}
|
||||||
if album:
|
if album:
|
||||||
metadata["xesam:album"] = Variant("s", album)
|
metadata["xesam:album"] = Variant("s", album)
|
||||||
@@ -125,11 +127,11 @@ class _RootInterface(ServiceInterface):
|
|||||||
|
|
||||||
@dbus_property(name="Identity", access=PropertyAccess.READ)
|
@dbus_property(name="Identity", access=PropertyAccess.READ)
|
||||||
def Identity(self) -> "s":
|
def Identity(self) -> "s":
|
||||||
return "SomaFM TUI"
|
return "Beefy Player"
|
||||||
|
|
||||||
@dbus_property(name="DesktopEntry", access=PropertyAccess.READ)
|
@dbus_property(name="DesktopEntry", access=PropertyAccess.READ)
|
||||||
def DesktopEntry(self) -> "s":
|
def DesktopEntry(self) -> "s":
|
||||||
return "somafm-tui"
|
return "beefy-player"
|
||||||
|
|
||||||
@dbus_property(name="CanQuit", access=PropertyAccess.READ)
|
@dbus_property(name="CanQuit", access=PropertyAccess.READ)
|
||||||
def CanQuit(self) -> "b":
|
def CanQuit(self) -> "b":
|
||||||
@@ -284,7 +286,7 @@ class _PlayerInterface(ServiceInterface):
|
|||||||
|
|
||||||
|
|
||||||
class MprisService:
|
class MprisService:
|
||||||
#: Status-bar name for this backend (see :mod:`somafm_tui.mediasession`).
|
#: Status-bar name for this backend (see :mod:`beefy_player.mediasession`).
|
||||||
label = "mpris"
|
label = "mpris"
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -306,7 +308,7 @@ class MprisService:
|
|||||||
self._status = "Stopped"
|
self._status = "Stopped"
|
||||||
self._metadata: dict[str, Any] = {}
|
self._metadata: dict[str, Any] = {}
|
||||||
self.bus_name = (
|
self.bus_name = (
|
||||||
f"org.mpris.MediaPlayer2.somafmtui.instance{os.getpid()}"
|
f"org.mpris.MediaPlayer2.beefyplayer.instance{os.getpid()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -317,7 +319,7 @@ class MprisService:
|
|||||||
if not self.enabled:
|
if not self.enabled:
|
||||||
return
|
return
|
||||||
self._thread = threading.Thread(
|
self._thread = threading.Thread(
|
||||||
target=self._thread_main, name="somafm-mpris", daemon=True
|
target=self._thread_main, name="beefy-player-mpris", daemon=True
|
||||||
)
|
)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
@@ -28,8 +28,7 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .api import USER_AGENT
|
from .config import RUNTIME_DIR, USER_AGENT, atomic_write_text, env_var
|
||||||
from .config import RUNTIME_DIR, atomic_write_text
|
|
||||||
|
|
||||||
#: mpv input bindings that turn the system media keys into TUI commands.
|
#: mpv input bindings that turn the system media keys into TUI commands.
|
||||||
#:
|
#:
|
||||||
@@ -39,20 +38,20 @@ from .config import RUNTIME_DIR, atomic_write_text
|
|||||||
#: list, the spectrum and the desktop "now playing" state) we bounce them back
|
#: list, the spectrum and the desktop "now playing" state) we bounce them back
|
||||||
#: over the IPC socket as ``client-message`` events.
|
#: over the IPC socket as ``client-message`` events.
|
||||||
MEDIA_KEY_BINDINGS = """\
|
MEDIA_KEY_BINDINGS = """\
|
||||||
# Generated by somafm-tui -- do not edit; it is rewritten on every launch.
|
# Generated by beefy-player -- do not edit; it is rewritten on every launch.
|
||||||
PLAY script-message somafm toggle
|
PLAY script-message beefy toggle
|
||||||
PLAYONLY script-message somafm play
|
PLAYONLY script-message beefy play
|
||||||
PAUSEONLY script-message somafm pause
|
PAUSEONLY script-message beefy pause
|
||||||
STOP script-message somafm stop
|
STOP script-message beefy stop
|
||||||
NEXT script-message somafm next
|
NEXT script-message beefy next
|
||||||
PREV script-message somafm prev
|
PREV script-message beefy prev
|
||||||
"""
|
"""
|
||||||
|
|
||||||
#: Commands a media key may ask for; everything else is ignored.
|
#: Commands a media key may ask for; everything else is ignored.
|
||||||
MEDIA_COMMANDS = frozenset({"play", "pause", "toggle", "stop", "next", "prev"})
|
MEDIA_COMMANDS = frozenset({"play", "pause", "toggle", "stop", "next", "prev"})
|
||||||
|
|
||||||
#: Prefix of the ``client-message`` namespace we listen on.
|
#: Prefix of the ``client-message`` namespace we listen on.
|
||||||
MEDIA_MESSAGE_PREFIX = "somafm"
|
MEDIA_MESSAGE_PREFIX = "beefy"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -113,7 +112,7 @@ class Player:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
self._thread = threading.Thread(
|
self._thread = threading.Thread(
|
||||||
target=self._thread_main, name="somafm-player", daemon=True
|
target=self._thread_main, name="beefy-player", daemon=True
|
||||||
)
|
)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
self._ready.wait(10)
|
self._ready.wait(10)
|
||||||
@@ -316,12 +315,12 @@ class Player:
|
|||||||
"--cache=yes",
|
"--cache=yes",
|
||||||
"--cache-secs=20",
|
"--cache-secs=20",
|
||||||
"--demuxer-max-bytes=32MiB",
|
"--demuxer-max-bytes=32MiB",
|
||||||
"--audio-client-name=somafm-tui",
|
"--audio-client-name=beefy-player",
|
||||||
f"--user-agent={USER_AGENT}",
|
f"--user-agent={USER_AGENT}",
|
||||||
"--network-timeout=20",
|
"--network-timeout=20",
|
||||||
]
|
]
|
||||||
command.extend(self._platform_options())
|
command.extend(self._platform_options())
|
||||||
extra = os.environ.get("SOMAFM_MPV_ARGS")
|
extra = env_var("MPV_ARGS")
|
||||||
if extra:
|
if extra:
|
||||||
command.extend(shlex.split(extra))
|
command.extend(shlex.split(extra))
|
||||||
|
|
||||||
@@ -466,7 +465,7 @@ class Player:
|
|||||||
self.events.put(PlayerEvent(kind="notice", detail=str(error)))
|
self.events.put(PlayerEvent(kind="notice", detail=str(error)))
|
||||||
|
|
||||||
def _handle_client_message(self, args: Any) -> None:
|
def _handle_client_message(self, args: Any) -> None:
|
||||||
"""Turn ``script-message somafm <command>`` into a player event.
|
"""Turn ``script-message beefy <command>`` into a player event.
|
||||||
|
|
||||||
mpv's macOS backend feeds the system media keys (and Control Center
|
mpv's macOS backend feeds the system media keys (and Control Center
|
||||||
buttons) into its input system; ``MEDIA_KEY_BINDINGS`` maps them onto
|
buttons) into its input system; ``MEDIA_KEY_BINDINGS`` maps them onto
|
||||||
@@ -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]
|
||||||
@@ -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}")
|
||||||
@@ -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)
|
||||||
@@ -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,27 +164,44 @@ 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()
|
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()
|
||||||
|
|
||||||
def snapshot(self) -> Frame:
|
def snapshot(self) -> Frame:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return Frame(
|
return 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:
|
||||||
break
|
if proc.poll() is not None:
|
||||||
|
reason = "eof"
|
||||||
|
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 {
|
||||||
@@ -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.
@@ -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
@@ -1,13 +1,13 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "somafm-tui"
|
name = "beefy-player"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "A flashy terminal radio for SomaFM with a live spectrum analyser, MPRIS / macOS Now Playing support and song history"
|
description = "A flashy terminal player for internet radio — live spectrum analyser, MPRIS / macOS Now Playing support, song history and pluggable station catalogues (SomaFM included)"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "WTFPL"
|
license = "WTFPL"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
authors = [{ name = "somafm-tui contributors" }]
|
authors = [{ name = "beefy-player contributors" }]
|
||||||
keywords = ["somafm", "radio", "tui", "terminal", "mpris", "audio"]
|
keywords = ["radio", "internet-radio", "tui", "terminal", "mpris", "audio", "somafm"]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Environment :: Console",
|
"Environment :: Console",
|
||||||
"Intended Audience :: End Users/Desktop",
|
"Intended Audience :: End Users/Desktop",
|
||||||
@@ -31,17 +31,17 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
somafm-tui = "somafm_tui.__main__:main"
|
beefy-player = "beefy_player.__main__:main"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["somafm_tui"]
|
packages = ["beefy_player"]
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel.force-include]
|
[tool.hatch.build.targets.wheel.force-include]
|
||||||
"somafm_tui/theme.tcss" = "somafm_tui/theme.tcss"
|
"beefy_player/theme.tcss" = "beefy_player/theme.tcss"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -60,8 +60,8 @@ extend-exclude = ["dist", ".venv", "build"]
|
|||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E4", "E7", "E9", "F", "I", "UP", "B", "SIM", "RUF"]
|
select = ["E4", "E7", "E9", "F", "I", "UP", "B", "SIM", "RUF"]
|
||||||
ignore = ["RUF001"] # en/em dashes are part of real SomaFM metadata
|
ignore = ["RUF001"] # en/em dashes are part of real station metadata
|
||||||
# dbus-next needs short signature strings ("s", "a{sv}", "x", …) in
|
# dbus-next needs short signature strings ("s", "a{sv}", "x", …) in
|
||||||
# annotations; they are not Python names.
|
# annotations; they are not Python names.
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
"somafm_tui/mpris.py" = ["F821", "F722", "UP037"]
|
"beefy_player/mpris.py" = ["F821", "F722", "UP037"]
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{ pkgs ? import <nixpkgs> { } }:
|
{ pkgs ? import <nixpkgs> { } }:
|
||||||
|
|
||||||
# FHS development shell for somafm-tui.
|
# FHS development shell for beefy-player.
|
||||||
#
|
#
|
||||||
# Why FHS? uv installs manylinux wheels from PyPI (notably NumPy) that expect
|
# Why FHS? uv installs manylinux wheels from PyPI (notably NumPy) that expect
|
||||||
# libstdc++.so.6 and friends in the usual FHS locations (/usr/lib, /lib).
|
# libstdc++.so.6 and friends in the usual FHS locations (/usr/lib, /lib).
|
||||||
@@ -12,13 +12,13 @@
|
|||||||
# nix-shell # enter the FHS shell
|
# nix-shell # enter the FHS shell
|
||||||
# rm -rf .venv # once, if the venv was created by an older setup
|
# rm -rf .venv # once, if the venv was created by an older setup
|
||||||
# uv sync # install Python deps with uv
|
# uv sync # install Python deps with uv
|
||||||
# uv run somafm-tui # start the radio
|
# uv run beefy-player # start the radio
|
||||||
#
|
#
|
||||||
# Note: `nix-shell --run "..."` is not supported by FHS shell.nix files; enter
|
# Note: `nix-shell --run "..."` is not supported by FHS shell.nix files; enter
|
||||||
# the shell interactively and run commands there.
|
# the shell interactively and run commands there.
|
||||||
|
|
||||||
(pkgs.buildFHSEnv {
|
(pkgs.buildFHSEnv {
|
||||||
name = "somafm-tui";
|
name = "beefy-player";
|
||||||
|
|
||||||
targetPkgs = pkgs: with pkgs; [
|
targetPkgs = pkgs: with pkgs; [
|
||||||
# --- Python toolchain; uv owns all Python dependencies ---------------
|
# --- Python toolchain; uv owns all Python dependencies ---------------
|
||||||
@@ -83,13 +83,13 @@
|
|||||||
export DBUS_SESSION_BUS_ADDRESS="unix:path=$XDG_RUNTIME_DIR/bus"
|
export DBUS_SESSION_BUS_ADDRESS="unix:path=$XDG_RUNTIME_DIR/bus"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
export PS1='\[\e[1;35m\][somafm-tui]\[\e[0m\] \w \$ '
|
export PS1='\[\e[1;35m\][beefy-player]\[\e[0m\] \w \$ '
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " somafm-tui — FHS dev shell"
|
echo " beefy-player — FHS dev shell"
|
||||||
echo " --------------------------"
|
echo " ----------------------------"
|
||||||
echo " uv sync install / update python deps"
|
echo " uv sync install / update python deps"
|
||||||
echo " uv run somafm-tui start the radio"
|
echo " uv run beefy-player start the radio"
|
||||||
echo ""
|
echo ""
|
||||||
echo " python : $(python3 --version 2>&1)"
|
echo " python : $(python3 --version 2>&1)"
|
||||||
echo " uv : $(uv --version 2>&1)"
|
echo " uv : $(uv --version 2>&1)"
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
"""A flashy terminal radio player for somafm.com."""
|
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
|
||||||
@@ -1,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 +0,0 @@
|
|||||||
"""Custom Textual widgets for somafm-tui."""
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -3,11 +3,11 @@ from __future__ import annotations
|
|||||||
import pytest
|
import pytest
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from somafm_tui import artwork
|
from beefy_player import artwork
|
||||||
from somafm_tui.app import SomaFMApp
|
from beefy_player.app import BeefyPlayerApp
|
||||||
from somafm_tui.widgets import art as art_widgets
|
from beefy_player.widgets import art as art_widgets
|
||||||
from somafm_tui.widgets.art import ArtPane, ITerm2Image, KittyImage
|
from beefy_player.widgets.art import ArtPane, ITerm2Image, KittyImage
|
||||||
from somafm_tui.widgets.stage import StagePanel
|
from beefy_player.widgets.stage import StagePanel
|
||||||
|
|
||||||
|
|
||||||
async def _no_fetch(client, url: str) -> None:
|
async def _no_fetch(client, url: str) -> None:
|
||||||
@@ -27,7 +27,7 @@ async def test_art_pane_switches_between_image_and_mosaic(monkeypatch) -> None:
|
|||||||
monkeypatch.setattr(artwork, "load_cached", lambda url: None)
|
monkeypatch.setattr(artwork, "load_cached", lambda url: None)
|
||||||
monkeypatch.setattr(artwork, "fetch", _no_fetch)
|
monkeypatch.setattr(artwork, "fetch", _no_fetch)
|
||||||
|
|
||||||
app = SomaFMApp(autoplay=False, offline=True, audio=False)
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
async with app.run_test(size=(110, 40)) as pilot:
|
async with app.run_test(size=(110, 40)) as pilot:
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
pane = app.query_one(ArtPane)
|
pane = app.query_one(ArtPane)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import httpx
|
|||||||
import pytest
|
import pytest
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from somafm_tui import artwork
|
from beefy_player import artwork
|
||||||
|
|
||||||
|
|
||||||
def png_bytes(size=(400, 200)) -> bytes:
|
def png_bytes(size=(400, 200)) -> bytes:
|
||||||
@@ -28,9 +28,11 @@ def test_protocol_detection() -> None:
|
|||||||
assert artwork.detect_protocol({"ZELLIJ": "1", "TERM_PROGRAM": "WezTerm"}) is None
|
assert artwork.detect_protocol({"ZELLIJ": "1", "TERM_PROGRAM": "WezTerm"}) is None
|
||||||
assert artwork.detect_protocol({"KITTY_PID": "1"}) == "tgp"
|
assert artwork.detect_protocol({"KITTY_PID": "1"}) == "tgp"
|
||||||
assert artwork.detect_protocol({"NO_COLOR": "", "KITTY_WINDOW_ID": "1"}) is None
|
assert artwork.detect_protocol({"NO_COLOR": "", "KITTY_WINDOW_ID": "1"}) is None
|
||||||
assert artwork.detect_protocol({"SOMAFM_IMAGES": "0", "KITTY_WINDOW_ID": "1"}) is None
|
assert artwork.detect_protocol({"BEEFY_IMAGES": "0", "KITTY_WINDOW_ID": "1"}) is None
|
||||||
assert artwork.detect_protocol({"SOMAFM_IMAGES": "tgp"}) == "tgp"
|
assert artwork.detect_protocol({"BEEFY_IMAGES": "tgp"}) == "tgp"
|
||||||
assert artwork.detect_protocol({"SOMAFM_IMAGES": "iterm2"}) == "iterm2"
|
assert artwork.detect_protocol({"BEEFY_IMAGES": "iterm2"}) == "iterm2"
|
||||||
|
# the pre-rename variable still works
|
||||||
|
assert artwork.detect_protocol({"SOMAFM_IMAGES": "0"}) is None
|
||||||
|
|
||||||
|
|
||||||
def test_fit_image_crops_to_the_cell_box() -> None:
|
def test_fit_image_crops_to_the_cell_box() -> None:
|
||||||
|
|||||||
+74
-18
@@ -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")
|
||||||
|
)
|
||||||
@@ -6,7 +6,7 @@ import types
|
|||||||
import pytest
|
import pytest
|
||||||
from rich.cells import cell_len
|
from rich.cells import cell_len
|
||||||
|
|
||||||
from somafm_tui.widgets.header import HeaderBar
|
from beefy_player.widgets.header import HeaderBar
|
||||||
|
|
||||||
TIME = re.compile(r"\d\d:\d\d:\d\d$")
|
TIME = re.compile(r"\d\d:\d\d:\d\d$")
|
||||||
DATE = re.compile(r"[A-Z][a-z]{2} \d\d [A-Z][a-z]{2}$")
|
DATE = re.compile(r"[A-Z][a-z]{2} \d\d [A-Z][a-z]{2}$")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from somafm_tui import macos_tap
|
from beefy_player import macos_tap
|
||||||
|
|
||||||
|
|
||||||
def test_osstatus_decodes_four_character_codes() -> None:
|
def test_osstatus_decodes_four_character_codes() -> None:
|
||||||
|
|||||||
+11
-1
@@ -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,8 +3,8 @@ from __future__ import annotations
|
|||||||
import queue
|
import queue
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from somafm_tui import mediasession
|
from beefy_player import mediasession
|
||||||
from somafm_tui.mediasession import (
|
from beefy_player.mediasession import (
|
||||||
MediaSession,
|
MediaSession,
|
||||||
NowPlayingSession,
|
NowPlayingSession,
|
||||||
create_media_session,
|
create_media_session,
|
||||||
@@ -12,7 +12,7 @@ from somafm_tui.mediasession import (
|
|||||||
|
|
||||||
|
|
||||||
def test_linux_uses_mpris() -> None:
|
def test_linux_uses_mpris() -> None:
|
||||||
from somafm_tui.mpris import MprisService
|
from beefy_player.mpris import MprisService
|
||||||
|
|
||||||
session = create_media_session(queue.Queue(), enabled=False, platform="linux")
|
session = create_media_session(queue.Queue(), enabled=False, platform="linux")
|
||||||
assert isinstance(session, MprisService)
|
assert isinstance(session, MprisService)
|
||||||
|
|||||||
+4
-4
@@ -4,7 +4,7 @@ import queue
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from somafm_tui import mpris
|
from beefy_player import mpris
|
||||||
|
|
||||||
|
|
||||||
def test_build_metadata() -> None:
|
def test_build_metadata() -> None:
|
||||||
@@ -17,16 +17,16 @@ def test_build_metadata() -> None:
|
|||||||
stream_url="https://ice2.somafm.com/groovesalad-256-mp3",
|
stream_url="https://ice2.somafm.com/groovesalad-256-mp3",
|
||||||
track_key="groovesalad|Jens Buchert|Rauschebart|Gravity",
|
track_key="groovesalad|Jens Buchert|Rauschebart|Gravity",
|
||||||
)
|
)
|
||||||
assert metadata["mpris:trackid"].value.startswith("/org/somafm/track/")
|
assert metadata["mpris:trackid"].value.startswith("/org/beefyplayer/track/")
|
||||||
assert metadata["xesam:title"].value == "Rauschebart"
|
assert metadata["xesam:title"].value == "Rauschebart"
|
||||||
assert metadata["xesam:artist"].value == ["Jens Buchert"]
|
assert metadata["xesam:artist"].value == ["Jens Buchert"]
|
||||||
assert metadata["xesam:album"].value == "Gravity"
|
assert metadata["xesam:album"].value == "Gravity"
|
||||||
assert metadata["mpris:artUrl"].value == "https://example.com/art.jpg"
|
assert metadata["mpris:artUrl"].value == "https://example.com/art.jpg"
|
||||||
assert metadata["xesam:url"].value.startswith("https://ice2.")
|
assert metadata["xesam:url"].value.startswith("https://ice2.")
|
||||||
assert metadata["xesam:comment"].value == ["SomaFM · Groove Salad"]
|
assert metadata["xesam:comment"].value == ["Beefy Player · Groove Salad"]
|
||||||
|
|
||||||
minimal = mpris.build_metadata(station="X", title="", artist="")
|
minimal = mpris.build_metadata(station="X", title="", artist="")
|
||||||
assert minimal["xesam:artist"].value == ["SomaFM"]
|
assert minimal["xesam:artist"].value == ["Beefy Player"]
|
||||||
assert "xesam:album" not in minimal
|
assert "xesam:album" not in minimal
|
||||||
assert "mpris:artUrl" not in minimal
|
assert "mpris:artUrl" not in minimal
|
||||||
|
|
||||||
|
|||||||
+11
-11
@@ -5,7 +5,7 @@ import queue
|
|||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from somafm_tui.player import Player
|
from beefy_player.player import Player
|
||||||
|
|
||||||
FAKE_MPV = '''#!/usr/bin/env python3
|
FAKE_MPV = '''#!/usr/bin/env python3
|
||||||
import json, os, socket, sys, time
|
import json, os, socket, sys, time
|
||||||
@@ -171,27 +171,27 @@ def test_backend_detection_without_tools(tmp_path, monkeypatch) -> None:
|
|||||||
def test_media_key_messages_become_player_events() -> None:
|
def test_media_key_messages_become_player_events() -> None:
|
||||||
player = Player()
|
player = Player()
|
||||||
player._handle_mpv_message(
|
player._handle_mpv_message(
|
||||||
{"event": "client-message", "args": ["somafm", "next"]}
|
{"event": "client-message", "args": ["beefy", "next"]}
|
||||||
)
|
)
|
||||||
event = player.events.get_nowait()
|
event = player.events.get_nowait()
|
||||||
assert event.kind == "media"
|
assert event.kind == "media"
|
||||||
assert event.detail == "next"
|
assert event.detail == "next"
|
||||||
|
|
||||||
player._handle_mpv_message(
|
player._handle_mpv_message(
|
||||||
{"event": "client-message", "args": ["somafm", "toggle"]}
|
{"event": "client-message", "args": ["beefy", "toggle"]}
|
||||||
)
|
)
|
||||||
assert player.events.get_nowait().detail == "toggle"
|
assert player.events.get_nowait().detail == "toggle"
|
||||||
|
|
||||||
# Foreign namespaces, unknown commands and malformed payloads are ignored.
|
# Foreign namespaces, unknown commands and malformed payloads are ignored.
|
||||||
player._handle_mpv_message({"event": "client-message", "args": ["other", "next"]})
|
player._handle_mpv_message({"event": "client-message", "args": ["other", "next"]})
|
||||||
player._handle_mpv_message({"event": "client-message", "args": ["somafm", "seek"]})
|
player._handle_mpv_message({"event": "client-message", "args": ["beefy", "seek"]})
|
||||||
player._handle_mpv_message({"event": "client-message", "args": None})
|
player._handle_mpv_message({"event": "client-message", "args": None})
|
||||||
player._handle_mpv_message({"event": "client-message"})
|
player._handle_mpv_message({"event": "client-message"})
|
||||||
assert player.events.empty()
|
assert player.events.empty()
|
||||||
|
|
||||||
|
|
||||||
def test_platform_options_never_leak_to_linux(tmp_path, monkeypatch) -> None:
|
def test_platform_options_never_leak_to_linux(tmp_path, monkeypatch) -> None:
|
||||||
import somafm_tui.player as player_module
|
import beefy_player.player as player_module
|
||||||
|
|
||||||
monkeypatch.setattr(player_module, "RUNTIME_DIR", tmp_path)
|
monkeypatch.setattr(player_module, "RUNTIME_DIR", tmp_path)
|
||||||
assert Player()._platform_options("linux") == []
|
assert Player()._platform_options("linux") == []
|
||||||
@@ -203,12 +203,12 @@ def test_platform_options_never_leak_to_linux(tmp_path, monkeypatch) -> None:
|
|||||||
assert conf.endswith("mpv-input.conf")
|
assert conf.endswith("mpv-input.conf")
|
||||||
bindings = (tmp_path / "mpv-input.conf").read_text()
|
bindings = (tmp_path / "mpv-input.conf").read_text()
|
||||||
for line in (
|
for line in (
|
||||||
"PLAY script-message somafm toggle",
|
"PLAY script-message beefy toggle",
|
||||||
"PLAYONLY script-message somafm play",
|
"PLAYONLY script-message beefy play",
|
||||||
"PAUSEONLY script-message somafm pause",
|
"PAUSEONLY script-message beefy pause",
|
||||||
"STOP script-message somafm stop",
|
"STOP script-message beefy stop",
|
||||||
"NEXT script-message somafm next",
|
"NEXT script-message beefy next",
|
||||||
"PREV script-message somafm prev",
|
"PREV script-message beefy prev",
|
||||||
):
|
):
|
||||||
assert line in bindings
|
assert line in bindings
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
@@ -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
@@ -5,15 +5,15 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from somafm_tui.app import SomaFMApp
|
from beefy_player.app import BeefyPlayerApp
|
||||||
from somafm_tui.widgets.header import HeaderBar
|
from beefy_player.widgets.header import HeaderBar
|
||||||
from somafm_tui.widgets.search import SearchInput
|
from beefy_player.widgets.search import SearchInput
|
||||||
from somafm_tui.widgets.spectrum import SpectrumPanel
|
from beefy_player.widgets.spectrum import SpectrumPanel
|
||||||
from somafm_tui.widgets.stage import StagePanel
|
from beefy_player.widgets.stage import StagePanel
|
||||||
from somafm_tui.widgets.station_list import StationList
|
from beefy_player.widgets.station_list import StationList
|
||||||
from somafm_tui.widgets.status import StatusBar
|
from beefy_player.widgets.status import StatusBar
|
||||||
|
|
||||||
PACKAGE = Path(__file__).resolve().parents[1] / "somafm_tui"
|
PACKAGE = Path(__file__).resolve().parents[1] / "beefy_player"
|
||||||
|
|
||||||
|
|
||||||
def test_no_rgb_colors_in_source() -> None:
|
def test_no_rgb_colors_in_source() -> None:
|
||||||
@@ -29,7 +29,7 @@ def test_no_rgb_colors_in_source() -> None:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_backgrounds_use_terminal_default_and_borders_are_ansi() -> None:
|
async def test_backgrounds_use_terminal_default_and_borders_are_ansi() -> None:
|
||||||
app = SomaFMApp(autoplay=False, offline=True, audio=False)
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
async with app.run_test(size=(100, 30)) as pilot:
|
async with app.run_test(size=(100, 30)) as pilot:
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from somafm_tui.widgets import common
|
from beefy_player.widgets import common
|
||||||
|
|
||||||
BERLIN = Path("/usr/share/zoneinfo/Europe/Berlin")
|
BERLIN = Path("/usr/share/zoneinfo/Europe/Berlin")
|
||||||
UTC_FILE = Path("/usr/share/zoneinfo/UTC")
|
UTC_FILE = Path("/usr/share/zoneinfo/UTC")
|
||||||
|
|||||||
Reference in new issue
Block a user