Compare commits
40
Commits
48e480d58c
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6684ddbbd | ||
|
|
d06a94f388 | ||
|
|
1f686ce7ba | ||
|
|
cbcdfae8bf | ||
|
|
ffd67fc90d | ||
|
|
941c744638 | ||
|
|
2753a7b6ba | ||
|
|
dc6ed00abe | ||
|
|
12792b1265 | ||
|
|
4346f75eac | ||
|
|
e0e1ad5df3 | ||
|
|
ec2ca72efb | ||
|
|
81116282cb | ||
|
|
03297e8751 | ||
|
|
638cc9eddb | ||
|
|
f96923b5b7 | ||
|
|
7ba2d50503 | ||
|
|
43ddd8666b | ||
|
|
253b462df1 | ||
|
|
5eb60d378c | ||
|
|
0219df454c | ||
|
|
49f7efe0d2 | ||
|
|
a3ad693132 | ||
|
|
b3bd8c1e9f | ||
|
|
e14a349c2d | ||
|
|
50a9a735f7 | ||
|
|
84ec3b08f6 | ||
|
|
ba114599d0 | ||
|
|
b31ad3f6fb | ||
|
|
058d336a2c | ||
|
|
58e9202e25 | ||
|
|
c4cc91ee1a | ||
|
|
e30ca09627 | ||
|
|
4bed90b489 | ||
|
|
a42678ef30 | ||
|
|
6d188c00bc | ||
|
|
b9d3d7e809 | ||
|
|
ca45303386 | ||
|
|
d2b4f1bf64 | ||
|
|
3ae4dc3dab |
No files matched your search
@@ -0,0 +1,38 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Notes for coding agents working in this repository.
|
||||||
|
|
||||||
|
## Re-sync the uv environment before doing anything
|
||||||
|
|
||||||
|
The checked-out `.venv` is **not** portable. It is built by `uv sync`, and its
|
||||||
|
`python` symlink points at a uv-managed interpreter path belonging to whichever
|
||||||
|
machine created it (for example `/home/node/.local/share/uv/python/...`). When
|
||||||
|
the repository is re-synced from the host, that interpreter is gone, the
|
||||||
|
symlink dangles, and every `.venv/bin/*` command fails with:
|
||||||
|
|
||||||
|
.venv/bin/python: No such file or directory
|
||||||
|
|
||||||
|
So always re-sync first — before tests, lint, the app, or any tooling:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
If it still fails, rebuild the environment once:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf .venv && uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no project Python on `PATH`; use `uv run <command>` or the freshly
|
||||||
|
synced `.venv/bin/python` explicitly.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync # resync the environment (do this first)
|
||||||
|
uv run pytest # full test suite
|
||||||
|
uvx ruff check . # lint (ruff is not a project dependency)
|
||||||
|
uv run beefy-player --list # station ids for the current catalogue
|
||||||
|
uv run python tools/ansi_art.py ... # regenerate a provider's header art
|
||||||
|
```
|
||||||
@@ -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,206 +1,210 @@
|
|||||||
# 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 album art, with desktop media integration on Linux and macOS.
|
||||||
terminal's own ANSI palette on the terminal's default background so it fits
|
It draws with your terminal's own ANSI palette on the terminal's default
|
||||||
any theme (including transparent terminals).
|
background, so it fits any theme, including transparent terminals.
|
||||||
|
|
||||||
It runs the same way on **Linux** (PulseAudio/PipeWire) and **macOS**
|
Thirteen station catalogues ship today, from SomaFM's curated channels to the
|
||||||
(CoreAudio, iTerm2/Kitty/WezTerm): MPRIS on the desktop bus where that exists,
|
62,000-station Radio Browser directory, loaded as you scroll and searched live
|
||||||
macOS Now Playing (Control Center and the media keys) where it does not.
|
as you type. Press `p` to switch catalogue without restarting.
|
||||||
|
|
||||||
No existing SomaFM client is used: the station list, playlists, song history,
|
|
||||||
audio control, spectrum visualiser and media-session integration are all
|
|
||||||
implemented here from the public SomaFM endpoints and open protocols.
|
|
||||||
|
|
||||||
## 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 palette
|
||||||
gradient taken from your terminal's ANSI palette and a symmetric mirrored
|
gradient and a mirrored spectrum. Six modes (`v`): mirrored bars, plain bars,
|
||||||
spectrum; or a full-height oscilloscope. Three modes (`v`).
|
oscilloscope, scrolling waterfall, radial spectrum and a high-resolution
|
||||||
- **Synced spectrum, not a guess** — by default the analyser records your
|
braille oscilloscope.
|
||||||
actual audio output, so the bars follow what you hear (`SYNC`): the
|
- **Synced to what you hear** — the analyser listens to your actual audio
|
||||||
PulseAudio/PipeWire monitor via `parec` on Linux, and a native Core Audio
|
output by default, so the bars follow the music. If that is unavailable it
|
||||||
process tap on macOS (no driver, no BlackHole, nothing to install). Without
|
falls back to decoding the stream, then to a simulated display.
|
||||||
an output capture it falls back to a muted second `ffmpeg` decode of the
|
- **Now playing and history** — artist, title, album, DJ, listeners, bitrate
|
||||||
stream (`FFT`, can drift), then to a synthesized display (`SIM`).
|
and "played N minutes ago", plus the full recent history. A clickable
|
||||||
- **Real artwork on iTerm2, Kitty and WezTerm** — Kitty uses the kitty
|
**Bandcamp ↗** link appears when the current track can be found there.
|
||||||
graphics protocol; iTerm2 (including on macOS) and WezTerm use their native
|
- **Real album art** — native images in Kitty, iTerm2 and WezTerm; an
|
||||||
iTerm2 inline-image protocol. Images occupy exactly the ON AIR art cell box
|
ANSI-shaded mosaic in every other terminal.
|
||||||
and fall back to the procedural ANSI mosaic for other terminals, failed
|
- **Thirteen station catalogues** — SomaFM (≈50 channels), Radio Paradise,
|
||||||
loads, or blocked graphics passthrough. Images are cached in
|
tilderadio, Bassdrive, dublab, Rinse FM, Refuge Worldwide, LYL Radio, The Lot
|
||||||
`~/.cache/somafm-tui/artwork`.
|
Radio, Cashmere Radio, Datafruits, aNONradio and the Radio Browser directory
|
||||||
- **Now playing** — artist, title, album, DJ, listeners, format/bitrate and
|
(loaded as you scroll in popularity order, with live search across all
|
||||||
"played N minutes ago", with procedural ANSI-shaded album art that changes
|
62,000).
|
||||||
per track. When the current track can be found on Bandcamp, a clickable
|
- **Favourites, search and schedules** — favourites float to the top and are
|
||||||
**Bandcamp ↗** link appears below it; otherwise the line stays hidden.
|
remembered between runs, including directory stations outside the loaded
|
||||||
- **Last played** — the full recent history from `somafm.com/songs/<id>.json`.
|
page; search by title, genre or description, with live server-side search
|
||||||
- **Every station** — the whole `channels.json` roster (≈50 channels), live
|
across the Radio Browser directory; single-stream stations show their
|
||||||
listener counts, genre search.
|
broadcast schedule instead of a one-item list.
|
||||||
- **Favourites** that float to the top, remembered between runs.
|
- **Desktop media integration** — MPRIS on Linux (GNOME/KDE widgets, lock
|
||||||
- **Desktop media integration** — on Linux an
|
screens, `playerctl` and the media keys) and Now Playing on macOS (Control
|
||||||
`org.mpris.MediaPlayer2.somafmtui.instance<pid>` player exposes
|
Center and the media keys).
|
||||||
`PlaybackStatus`, `Metadata` (title/artist/album/artUrl), `Volume`, and
|
- **Sixteen palettes, theme-aware** — every colour is native ANSI against the
|
||||||
`Play`/`Pause`/`PlayPause`/`Stop`/`Next`/`Previous`, so GNOME/KDE widgets,
|
terminal's default background, so transparent terminals stay transparent and
|
||||||
lock screens, `playerctl` and the media keys pick it up. On macOS the audio
|
the TUI follows your colour scheme. Press `c` to cycle palettes.
|
||||||
engine (mpv) publishes to the system **Now Playing** session, so Control
|
- **Graceful offline** — cached stations and schedules, a bundled fallback
|
||||||
Center, the menu bar and the keyboard media keys drive the app the same way.
|
list, automatic mirror failover and `ffplay` when `mpv` is missing.
|
||||||
- **Theme-aware** — every accent is emitted as native ANSI (not RGB), and the
|
|
||||||
background is the terminal default (`SGR 49`), so transparent terminals stay
|
|
||||||
transparent and the TUI adopts your shell/terminal colour scheme.
|
|
||||||
- **Graceful degradation** — works with cached data offline, falls back to a
|
|
||||||
bundled station list, reconnects across ICE mirrors automatically, and uses
|
|
||||||
`ffplay` if `mpv` is missing.
|
|
||||||
|
|
||||||
## Requirements
|
## Installation
|
||||||
|
|
||||||
`somafm-tui` is a normal Python application: there is nothing to compile and
|
### Nix
|
||||||
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`.
|
|
||||||
|
|
||||||
| tool | why |
|
The flake builds the package, runs the test suite and wraps everything the app
|
||||||
| ---- | --- |
|
calls at runtime (`mpv`, `ffmpeg`, and PulseAudio's `parec`/`pactl` — PipeWire
|
||||||
| `python3` | runtime (3.10 or newer; no exact version pin) |
|
speaks the same protocol):
|
||||||
| [`uv`](https://docs.astral.sh/uv/) | creates the venv and installs every Python dependency from `uv.lock` |
|
|
||||||
| `mpv` | playback and JSON IPC control |
|
|
||||||
| `ffmpeg` | muted stream decode for the fallback analyser |
|
|
||||||
| `parec` / `pactl` *(Linux)* | synced monitor capture for the spectrum (`pulseaudio-utils`; works with PipeWire) |
|
|
||||||
| D-Bus session bus *(Linux)* | MPRIS desktop media integration (`playerctl` is handy for testing) |
|
|
||||||
|
|
||||||
On macOS the spectrum capture is part of the app: it uses the Core Audio
|
|
||||||
process-tap API (macOS 14.2 or newer), so there is no loopback driver to
|
|
||||||
install.
|
|
||||||
|
|
||||||
On macOS the media integration needs nothing extra: mpv is what the system's
|
|
||||||
Now Playing session (Control Center, media keys) talks to.
|
|
||||||
|
|
||||||
Install `uv` once (skip if it is already there):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
nix run git+https://tildeforge.dev/beefcarver/beefy-player # try it
|
||||||
|
nix profile install git+https://tildeforge.dev/beefcarver/beefy-player # install it
|
||||||
```
|
```
|
||||||
|
|
||||||
`brew install uv`, `pipx install uv`, or a distro package also work. If your
|
In a NixOS configuration:
|
||||||
system Python is older than 3.10, let uv manage one: `uv python install 3.12`.
|
|
||||||
|
|
||||||
### Linux
|
```nix
|
||||||
|
{
|
||||||
|
inputs.beefy-player.url = "git+https://tildeforge.dev/beefcarver/beefy-player";
|
||||||
|
|
||||||
Install these with your package manager. Names are usually:
|
outputs = { nixpkgs, beefy-player, ... }: {
|
||||||
|
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
|
||||||
```bash
|
modules = [
|
||||||
# Debian / Ubuntu
|
beefy-player.nixosModules.default
|
||||||
sudo apt install python3 mpv ffmpeg pulseaudio-utils dbus playerctl
|
{
|
||||||
|
programs.beefy-player.enable = true;
|
||||||
# Fedora (enable RPM Fusion for ffmpeg if needed)
|
}
|
||||||
sudo dnf install python3 mpv ffmpeg pulseaudio-utils dbus-tools playerctl
|
];
|
||||||
|
};
|
||||||
# Arch / Manjaro
|
};
|
||||||
sudo pacman -S python mpv ffmpeg libpulse dbus playerctl
|
}
|
||||||
|
|
||||||
# openSUSE
|
|
||||||
sudo zypper install python3 mpv ffmpeg pulseaudio-utils dbus-1-tools playerctl
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For other distributions, install the equivalents. On a headless machine you
|
Or, for a single user with Home Manager:
|
||||||
can skip the audio/D-Bus tools and use `--no-audio` for a UI-only demo, though
|
|
||||||
the synced spectrum needs `parec`/`pactl`.
|
|
||||||
|
|
||||||
### macOS
|
```nix
|
||||||
|
{
|
||||||
|
inputs.beefy-player.url = "git+https://tildeforge.dev/beefcarver/beefy-player";
|
||||||
|
|
||||||
```bash
|
outputs = { home-manager, beefy-player, ... }: {
|
||||||
brew install mpv ffmpeg uv # ffplay ships with ffmpeg
|
homeConfigurations.me = home-manager.lib.homeManagerConfiguration {
|
||||||
|
modules = [
|
||||||
|
beefy-player.homeModules.default
|
||||||
|
{
|
||||||
|
programs.beefy-player.enable = true;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
That is enough to run: playback, artwork in iTerm2/Kitty/WezTerm, the synced
|
Both modules add one option, `programs.beefy-player.package`, so a pinned or
|
||||||
Core Audio spectrum tap and the macOS Now Playing integration all work out of
|
overridden build can be substituted for the flake's own. The raw package
|
||||||
the box.
|
(`packages.${system}.default`) and an overlay (`overlays.default`) are exported
|
||||||
|
too. From a checkout, `nix develop` gives you `uv`, `ruff` and the audio tools
|
||||||
|
for the usual `uv sync` / `uv run pytest` workflow; `nix build` builds and
|
||||||
|
tests the package.
|
||||||
|
|
||||||
The first time the analyser starts it asks macOS for **System Audio
|
### Arch Linux
|
||||||
Recording** permission — a one-off prompt for your terminal (iTerm2, Kitty,
|
|
||||||
WezTerm, …). Approve it under *System Settings → Privacy & Security → System
|
|
||||||
Audio Recording*; no loopback driver, Multi-Output Device or audio routing is
|
|
||||||
needed. If the permission is denied the tap only ever hands out silence, the
|
|
||||||
analyser notices and falls back to `FFT` instead of showing dead bars.
|
|
||||||
|
|
||||||
The tap records the **default output device**, which is also what mpv uses; if
|
The `aur/PKGBUILD` here is AUR-ready, but the package is not published in the
|
||||||
you point mpv elsewhere with `SOMAFM_MPV_ARGS='--audio-device=...'` the
|
AUR: the AUR has closed new sign-ups, so there is no account to push it to.
|
||||||
analyser falls back to `FFT`.
|
Build and install it from a checkout instead:
|
||||||
|
|
||||||
Terminal image rendering uses [Pillow](https://python-pillow.org/) and is
|
|
||||||
implemented directly (Kitty TGP + iTerm2 OSC 1337) — no terminal probing.
|
|
||||||
|
|
||||||
## Run
|
|
||||||
|
|
||||||
From the repository root, sync the environment and start the app:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync # create .venv and install exactly what uv.lock pins
|
git clone https://tildeforge.dev/beefcarver/beefy-player
|
||||||
uv run somafm-tui # run inside that environment
|
cd beefy-player/aur
|
||||||
|
makepkg -si # needs base-devel; builds, runs the test suite, installs
|
||||||
```
|
```
|
||||||
|
|
||||||
You do **not** need to activate the venv. `uv run` uses `.venv` automatically,
|
Once the AUR accepts new accounts again, the package can be published and
|
||||||
and re-syncs it first if `pyproject.toml` or `uv.lock` changed. If you prefer a
|
`paru -S beefy-player` / `yay -S beefy-player` will work; until then `makepkg`
|
||||||
traditional shell, run `source .venv/bin/activate` and then `somafm-tui`.
|
is the only route. The `aur/PKGBUILD` is tracked and kept in sync with every
|
||||||
|
release either way.
|
||||||
|
|
||||||
### Keeping the uv environment in sync
|
The Python side is pulled in automatically. For audio, the output-synced
|
||||||
|
spectrum and image passthrough through tmux, install the optional tools too:
|
||||||
`uv sync` is the command to remember. It is incremental, safe to re-run, and
|
|
||||||
should be run after pulling changes, switching branches, or editing
|
|
||||||
`pyproject.toml`:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync # make .venv match uv.lock
|
sudo pacman -S mpv ffmpeg libpulse tmux
|
||||||
uv sync --frozen # strict: fail if uv.lock is out of date, never rewrite it
|
|
||||||
uv lock --upgrade # refresh uv.lock to the newest allowed versions
|
|
||||||
uv sync --upgrade # upgrade dependencies and sync .venv in one step
|
|
||||||
rm -rf .venv && uv sync # last resort if the venv was built by an older Python
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Useful flags:
|
### From source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run somafm-tui --play # start the last station immediately
|
uv sync
|
||||||
uv run somafm-tui --station defcon # pick a station
|
uv run beefy-player
|
||||||
uv run somafm-tui --volume 55
|
```
|
||||||
uv run somafm-tui --offline # no network; cached/bundled stations
|
|
||||||
uv run somafm-tui --no-mpris # no MPRIS / Now Playing, no media keys
|
## Usage
|
||||||
uv run somafm-tui --list # print station ids
|
|
||||||
uv run somafm-tui --no-audio # UI demo without mpv/ffmpeg
|
```bash
|
||||||
uv run somafm-tui --spectrum-source monitor # force synced output capture
|
beefy-player
|
||||||
uv run somafm-tui --spectrum-source stream # force the muted ffmpeg tap
|
|
||||||
SOMAFM_IMAGES=0 uv run somafm-tui # force the ANSI artwork mosaic
|
|
||||||
SOMAFM_IMAGES=tgp uv run somafm-tui # force Kitty/TGP artwork
|
|
||||||
SOMAFM_IMAGES=iterm2 uv run somafm-tui # force iTerm2/WezTerm artwork
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
| flag | what it does |
|
||||||
|
| --- | --- |
|
||||||
|
| `--provider <slug>` | pick a station catalogue |
|
||||||
|
| `--station <id>` | pick a station (see `--list`) |
|
||||||
|
| `--play` | tune the last station right away |
|
||||||
|
| `--list` | print station ids for the current catalogue |
|
||||||
|
| `--volume <0-100>` | set the initial volume |
|
||||||
|
| `--offline` | no network; use cached and bundled stations |
|
||||||
|
| `--no-audio` | UI demo without `mpv` / `ffmpeg` |
|
||||||
|
| `--no-mpris` | no MPRIS / Now Playing, no media keys |
|
||||||
|
| `--spectrum-source monitor\|stream` | force synced output capture or the stream tap |
|
||||||
|
|
||||||
| key | action |
|
Environment variables are prefixed `BEEFY_`: `BEEFY_IMAGES` (`tgp`, `iterm2`
|
||||||
| -------------- | --------------------------------------------- |
|
or `0`), `BEEFY_MPV_ARGS` and `BEEFY_SPECTRUM_DEVICE`.
|
||||||
| `↑` / `↓`, `j` / `k` | move through stations |
|
|
||||||
| `enter` | tune in to the selected station |
|
|
||||||
| `space` | play / pause |
|
|
||||||
| `s` | stop |
|
|
||||||
| `]` / `[` | next / previous station (tunes immediately) |
|
|
||||||
| `+` / `-` | volume |
|
|
||||||
| `m` | mute |
|
|
||||||
| `f` | favourite (favourites sort to the top) |
|
|
||||||
| `/` | filter by title, genre, description |
|
|
||||||
| `esc` | clear filter / close search |
|
|
||||||
| `v` | visualiser: mirrored bars → bars → wave |
|
|
||||||
| `r` | refresh channels + song history |
|
|
||||||
| `g` / `G` | top / bottom |
|
|
||||||
| `?` | help |
|
|
||||||
| `q` | quit |
|
|
||||||
|
|
||||||
## Desktop media widgets (MPRIS / Now Playing)
|
### Keys
|
||||||
|
|
||||||
**Linux** — while playing, the app exports
|
| key | action |
|
||||||
`org.mpris.MediaPlayer2.somafmtui.instance<pid>` on the **session bus**. Check it:
|
| --- | --- |
|
||||||
|
| `↑` / `↓`, `j` / `k` | move through stations |
|
||||||
|
| `pgup` / `pgdn` | page through stations |
|
||||||
|
| `enter` | tune in to the selected station |
|
||||||
|
| `space` | play / pause |
|
||||||
|
| `s` | stop |
|
||||||
|
| `]` / `[` | next / previous station (tunes immediately) |
|
||||||
|
| `+` / `-` | volume |
|
||||||
|
| `m` | mute |
|
||||||
|
| `f` | favourite (favourites sort to the top) |
|
||||||
|
| `/` | filter by title, genre, description |
|
||||||
|
| `esc` | close the search box; press again to clear the filter |
|
||||||
|
| `v` | visualiser: mirror → bars → wave → waterfall → radial → braille |
|
||||||
|
| `c` | colour palette (16 of them, remembered) |
|
||||||
|
| `r` | refresh stations, schedule and song history |
|
||||||
|
| `p` | switch station catalogue |
|
||||||
|
| `g` / `G` | top / bottom |
|
||||||
|
| `?` | help |
|
||||||
|
| `q` | quit |
|
||||||
|
|
||||||
|
## Station catalogues
|
||||||
|
|
||||||
|
| catalogue | slug | stations |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| [SomaFM](https://somafm.com) | `somafm` | ≈50 curated channels |
|
||||||
|
| [Radio Paradise](https://radioparadise.com) | `radioparadise` | 5 mixes |
|
||||||
|
| [tilderadio](https://tilderadio.org) | `tilderadio` | 1 + schedule |
|
||||||
|
| [Bassdrive](https://bassdrive.com) | `bassdrive` | 1 + schedule |
|
||||||
|
| [dublab](https://dublab.com) | `dublab` | 1 + schedule |
|
||||||
|
| [Rinse FM](https://rinse.fm) | `rinse` | 3 |
|
||||||
|
| [Refuge Worldwide](https://refugeworldwide.com) | `refuge` | 1 |
|
||||||
|
| [LYL Radio](https://lyl.live) | `lyl` | 1 |
|
||||||
|
| [The Lot Radio](https://thelotradio.com) | `lot` | 1 |
|
||||||
|
| [Cashmere Radio](https://cashmereradio.com) | `cashmere` | 1 |
|
||||||
|
| [Datafruits](https://datafruits.fm) | `datafruits` | 1 |
|
||||||
|
| [aNONradio](https://anonradio.net) | `anonradio` | 1 |
|
||||||
|
| [Radio Browser](https://www.radio-browser.info) | `radiobrowser` | 62k · paged + live search |
|
||||||
|
|
||||||
|
Pick one with `p` in the UI, `--provider <slug>` on the command line, or by
|
||||||
|
setting `"provider"` in the config file.
|
||||||
|
|
||||||
|
## Desktop media integration
|
||||||
|
|
||||||
|
**Linux** — while playing, the app exposes an
|
||||||
|
`org.mpris.MediaPlayer2.beefyplayer.instance<pid>` player on the session bus,
|
||||||
|
so GNOME/KDE widgets, lock screens, `playerctl` and the media keys pick it up:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
playerctl -l
|
playerctl -l
|
||||||
@@ -208,152 +212,64 @@ 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
|
If there is no session bus (SSH without `dbus-run-session`, containers, …)
|
||||||
channel logo. If there is no session bus (SSH without `dbus-run-session`,
|
MPRIS is skipped and the status bar shows `mpris off`.
|
||||||
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 publishes to the system Now Playing session, so Control Center,
|
||||||
menu-bar widget and the keyboard media keys (▶︎/⏸, ⏭, ⏮) all target this app
|
the menu bar and the media keys drive the app while it plays. The status bar
|
||||||
while it is playing. mpv publishes the live stream title (the same
|
shows `nowplaying on`. `--no-mpris` (also spelled `--no-media`) turns the media
|
||||||
“Artist - Title” that SomaFM sends with the audio), and the media keys are
|
keys off.
|
||||||
routed back into the TUI, so `⏭`/`⏮` really change station and the panel, the
|
|
||||||
spectrum and the station list stay in sync. The status bar shows
|
|
||||||
`nowplaying on`. There is no D-Bus and no extra dependency involved, and
|
|
||||||
`--no-mpris` (also spelled `--no-media`) turns the media keys off again.
|
|
||||||
|
|
||||||
> macOS shows one Now Playing app at a time. If Music/Safari/another mpv is
|
> macOS shows one Now Playing app at a time. If Music, Safari or another mpv is
|
||||||
> playing, the media keys go there instead — quit it or pause it to hand the
|
> playing, the media keys go there instead.
|
||||||
> keys back.
|
|
||||||
|
|
||||||
## How it works
|
## Configuration
|
||||||
|
|
||||||
- `somafm_tui/api.py` — async `httpx` client for:
|
Settings live in `~/.config/beefy-player/config.json` (favourites, volume,
|
||||||
- `channels.json` → stations, genres, listeners, `.pls` playlist URLs
|
provider and the last station tuned in each catalogue). Cached stations,
|
||||||
- `<station>.pls` → resolved direct icecast URLs (cached 8h)
|
schedules and artwork live in `~/.cache/beefy-player/`.
|
||||||
- `songs/<station>.json` → last played tracks (cached on disk)
|
|
||||||
Playlist and channel caching means the UI opens instantly and still works
|
|
||||||
offline.
|
|
||||||
- `somafm_tui/player.py` — mpv over a Unix-socket JSON IPC channel. Observes
|
|
||||||
`pause`/`volume`, handles mirror failover on `end-file`, auto-restarts a dead
|
|
||||||
engine, and uses `SIGSTOP`/`SIGCONT` when falling back to `ffplay`. On macOS
|
|
||||||
it also asks mpv to publish Now Playing and maps the media keys onto
|
|
||||||
`script-message`s that come back over the same IPC socket.
|
|
||||||
- `somafm_tui/spectrum.py` — analyser (44.1 kHz mono s16le from `parec`, or
|
|
||||||
the device's native float32 rate from the macOS tap). Prefers capturing the
|
|
||||||
real output: `parec` (PulseAudio/PipeWire monitor) on Linux, or a native
|
|
||||||
Core Audio process tap on macOS (SYNC, see `macos_tap.py`). Otherwise a
|
|
||||||
muted low-latency `ffmpeg` decode of the stream (FFT). Hann-windowed
|
|
||||||
2048-point FFT, log band grouping, auto-gain, fast attack/decay smoothing,
|
|
||||||
peak hold and waveform downsampling.
|
|
||||||
- `somafm_tui/macos_tap.py` — the macOS output capture, in pure `ctypes`
|
|
||||||
against Core Audio: `CATapDescription` → `AudioHardwareCreateProcessTap` →
|
|
||||||
private aggregate device → IOProc. No driver, no helper binary, no ffmpeg.
|
|
||||||
- `somafm_tui/mediasession.py` — picks the desktop media backend (MPRIS on
|
|
||||||
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
|
|
||||||
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
|
|
||||||
the now-playing track (artist + track first, then artist + album, then the
|
|
||||||
album page) and returns a track URL when there is a match.
|
|
||||||
- `somafm_tui/widgets/` — Textual widgets rendered with native ANSI colours;
|
|
||||||
backgrounds are `ansi_default` (`SGR 49`), so transparent terminals stay
|
|
||||||
transparent.
|
|
||||||
|
|
||||||
### Files
|
These stay in the usual XDG locations on every platform, macOS included;
|
||||||
|
`XDG_CONFIG_HOME` and `XDG_CACHE_HOME` are honoured when set.
|
||||||
|
|
||||||
```
|
## Troubleshooting
|
||||||
~/.config/somafm-tui/config.json favourites, volume, last station, mode
|
|
||||||
~/.cache/somafm-tui/channels.json last good station list
|
|
||||||
~/.cache/somafm-tui/playlists.json resolved stream URLs
|
|
||||||
~/.cache/somafm-tui/songs/<id>.json song history snapshots
|
|
||||||
~/.cache/somafm-tui/artwork/<sha1> cached album/channel artwork
|
|
||||||
```
|
|
||||||
|
|
||||||
These stay in the XDG locations on every platform (macOS included), so the
|
- **Artwork shows the mosaic?** Real images are auto-enabled for Kitty, iTerm2
|
||||||
same dotfiles/config work everywhere; `XDG_CONFIG_HOME`, `XDG_CACHE_HOME` and
|
and WezTerm. Force a protocol with `BEEFY_IMAGES=tgp` (Kitty) or
|
||||||
`XDG_RUNTIME_DIR` are honoured when set.
|
`BEEFY_IMAGES=iterm2` (iTerm2/WezTerm); `BEEFY_IMAGES=0` forces the mosaic.
|
||||||
|
`--diagnose-images` prints what was detected.
|
||||||
## Notes / troubleshooting
|
- **No audio?** `mpv` is preferred, `ffplay` works as a fallback. Without
|
||||||
|
either, the UI still runs and tells you. Extra mpv arguments can be injected
|
||||||
- **Artwork shows the mosaic?** Real images are auto-enabled for Kitty,
|
with `BEEFY_MPV_ARGS`, e.g. `BEEFY_MPV_ARGS='--audio-device=...'`.
|
||||||
iTerm2 and WezTerm. Force a protocol with `SOMAFM_IMAGES=tgp` (Kitty/TGP) or
|
- **Spectrum says `FFT` instead of `SYNC`?** Output capture is unavailable or
|
||||||
`SOMAFM_IMAGES=iterm2` (iTerm2/WezTerm); `SOMAFM_IMAGES=0` forces the ANSI
|
failed, so the analyser is decoding the stream instead; the bars are still
|
||||||
mosaic. iTerm2 on macOS is detected from `TERM_PROGRAM`/`LC_TERMINAL`, and
|
real audio but can drift. Force sync with `--spectrum-source monitor`, or
|
||||||
empty or `0` values in `TMUX`/`ZELLIJ`/`STY` (exported by some wrappers) are
|
pick a specific monitor with `BEEFY_SPECTRUM_DEVICE` on Linux. `SIM` means the
|
||||||
ignored so they cannot downgrade it. Kitty inside tmux is attempted through
|
bars are synthesized.
|
||||||
tmux passthrough (enable `set -g allow-passthrough on`); iTerm2/WezTerm
|
|
||||||
inside tmux default to the mosaic. Images are cached under
|
|
||||||
`~/.cache/somafm-tui/artwork`.
|
|
||||||
- **Clock shows UTC?** The clock and song history use the system zone from
|
|
||||||
`/etc/localtime`; a non-UTC `TZ` overrides it. A container or dev shell can
|
|
||||||
inherit a build-time `TZ=UTC`, which the app deliberately ignores; bind the
|
|
||||||
host's `/etc/localtime` or set e.g. `TZ=Europe/Berlin` in the shell that
|
|
||||||
launches the app.
|
|
||||||
- **MPRIS says off/error?** No session bus. Run from your desktop session
|
|
||||||
(the shell profile picks up `$XDG_RUNTIME_DIR/bus`). `dbus-run-session -- uv
|
|
||||||
run somafm-tui` starts a private bus for testing, but desktop widgets won't
|
|
||||||
see it. macOS does not use MPRIS at all — see “Now Playing” above.
|
|
||||||
- **No audio?** `somafm-tui` needs `mpv` (preferred) or `ffplay`. Without
|
|
||||||
either, the UI still runs and tells you. Extra mpv args can be injected with
|
|
||||||
`SOMAFM_MPV_ARGS` (e.g. `SOMAFM_MPV_ARGS='--audio-device=...'`, which is the
|
|
||||||
macOS way to choose an output device).
|
|
||||||
- **Spectrum says `FFT` instead of `SYNC`?** The monitor is unavailable or
|
|
||||||
failed, or you forced the stream tap. The bars are still real audio, but the
|
|
||||||
second stream can drift relative to playback. Use `--spectrum-source
|
|
||||||
monitor` to force sync, or `SOMAFM_SPECTRUM_DEVICE=<source>` on Linux to pick
|
|
||||||
a specific monitor. `SIM` means the bars are synthesized.
|
|
||||||
- **Diagnose artwork:** `uv run somafm-tui --diagnose-images` prints the
|
|
||||||
detected protocol, widget class, terminal env, dependency versions and cache
|
|
||||||
path. If `protocol` is `None`, force it with `SOMAFM_IMAGES=tgp` (Kitty) or
|
|
||||||
`SOMAFM_IMAGES=iterm2` (iTerm2/WezTerm).
|
|
||||||
- **`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
|
|
||||||
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 under *System Settings → Privacy & Security → System
|
||||||
Recording*), then restart the app; the status bar explains when the tap is
|
Audio Recording*, then restart the app. Also check that mpv is playing to the
|
||||||
silent because of this. Also check that mpv is playing to the default output
|
default output device, since that is what the analyser records.
|
||||||
device, since that is what the tap records.
|
- **macOS: media keys do nothing?** Another app owns the Now Playing session,
|
||||||
- **macOS: media keys do nothing?** Another app currently owns the Now Playing
|
the app was started with `--no-mpris`, or audio fell back to `ffplay` (mpv is
|
||||||
session, or the app was started with `--no-mpris`, or the audio backend fell
|
required for Now Playing).
|
||||||
back to `ffplay` (mpv is required for Now Playing). The status bar shows
|
- **MPRIS says off?** There is no session bus. Run from your desktop session,
|
||||||
`nowplaying on` when the integration is armed.
|
or use `dbus-run-session` for a private bus. macOS does not use MPRIS.
|
||||||
- **macOS: Control Center shows only a title?** mpv publishes the stream's live
|
|
||||||
“Artist - Title” as the Now Playing title; artist/album fields and artwork are
|
|
||||||
not filled in by macOS for plain audio streams. The TUI itself always shows
|
|
||||||
the full metadata and album art.
|
|
||||||
- **Bandwidth** is unchanged in SYNC mode (the monitor capture creates no
|
|
||||||
second stream). The FFT fallback opens one extra stream; if you are on a
|
|
||||||
metered link, use `--spectrum-source monitor` (or `--no-audio` for a UI-only
|
|
||||||
demo).
|
|
||||||
- SomaFM is listener-supported: if you enjoy it, [donate](https://somafm.com/support/).
|
|
||||||
|
|
||||||
## NixOS (optional aside)
|
## Development
|
||||||
|
|
||||||
None of the above needs Nix. If you *are* on NixOS, `shell.nix` wraps the same
|
|
||||||
`uv sync` / `uv run` workflow in an FHS environment, because uv installs
|
|
||||||
manylinux wheels such as NumPy that expect `libstdc++.so.6` in the usual
|
|
||||||
`/usr/lib` paths:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nix-shell # enter the FHS dev shell
|
uv sync # create the environment
|
||||||
uv sync
|
uv run pytest # test suite
|
||||||
uv run somafm-tui
|
uvx ruff check . # lint
|
||||||
```
|
```
|
||||||
|
|
||||||
- If you see `libstdc++.so.6: cannot open shared object file`, make sure you
|
Each catalogue is a self-contained provider module under
|
||||||
are inside `nix-shell`, then recreate the venv once with
|
`beefy_player/providers/`; `base.py` documents the interface. `tools/ansi_art.py`
|
||||||
`rm -rf .venv && uv sync`.
|
converts a PNG logo into half-block ANSI art. On NixOS, `nix develop` (or the
|
||||||
- `nix-shell --run "..."` does not work with FHS `shell.nix` files; enter the
|
older `shell.nix`) provides the same `uv sync` / `uv run` workflow.
|
||||||
shell interactively and run commands there.
|
|
||||||
- FHS shells need unprivileged user namespaces. `bwrap: No permissions to
|
|
||||||
create a new namespace` means they are disabled (for example in a locked-down
|
|
||||||
container); run the app on the host instead.
|
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
WTFPL — Do What The Fuck You Want To Public License, Version 2. See
|
WTFPL — Do What The Fuck You Want To Public License, Version 2.
|
||||||
[LICENSE](LICENSE). SomaFM and its channel names are trademarks of
|
See [LICENSE](LICENSE).
|
||||||
SomaFM.com, LLC.
|
|
||||||
|
|
||||||
Terminal image rendering uses [Pillow](https://python-pillow.org/).
|
|
||||||
File renamed without changes.
@@ -0,0 +1,28 @@
|
|||||||
|
pkgbase = beefy-player
|
||||||
|
pkgdesc = Flashy terminal player for internet radio with a live spectrum analyser
|
||||||
|
pkgver = 1.2.2
|
||||||
|
pkgrel = 1
|
||||||
|
url = https://tildeforge.dev/beefcarver/beefy-player
|
||||||
|
arch = any
|
||||||
|
license = WTFPL
|
||||||
|
depends = python
|
||||||
|
depends = python-dbus-next
|
||||||
|
depends = python-httpx
|
||||||
|
depends = python-numpy
|
||||||
|
depends = python-pillow
|
||||||
|
depends = python-rich
|
||||||
|
depends = python-textual
|
||||||
|
makedepends = python-build
|
||||||
|
makedepends = python-hatchling
|
||||||
|
makedepends = python-installer
|
||||||
|
makedepends = python-wheel
|
||||||
|
checkdepends = python-pytest
|
||||||
|
checkdepends = python-pytest-asyncio
|
||||||
|
optdepends = ffmpeg: decode the stream for the spectrum analyser, and ffplay as the audio fallback
|
||||||
|
optdepends = libpulse: capture the output monitor so the analyser follows what you hear (parec/pactl)
|
||||||
|
optdepends = mpv: preferred audio backend
|
||||||
|
optdepends = tmux: detect the outer terminal for kitty graphics passthrough inside tmux
|
||||||
|
source = beefy-player-1.2.2.tar.gz::https://tildeforge.dev/beefcarver/beefy-player/archive/941c7446384bf346e40d1438a7ce67939d9615df.tar.gz
|
||||||
|
sha256sums = 8ac568676ef5ef8345441c28e4f8f5d61fb06d5225e566ac578acae18672c778
|
||||||
|
|
||||||
|
pkgname = beefy-player
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Maintainer: beefy-player contributors <beefcarver@tilde.club>
|
||||||
|
|
||||||
|
pkgname=beefy-player
|
||||||
|
pkgver=1.2.2
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="Flashy terminal player for internet radio with a live spectrum analyser"
|
||||||
|
arch=('any')
|
||||||
|
url="https://tildeforge.dev/beefcarver/beefy-player"
|
||||||
|
license=('WTFPL')
|
||||||
|
depends=(
|
||||||
|
'python'
|
||||||
|
'python-dbus-next'
|
||||||
|
'python-httpx'
|
||||||
|
'python-numpy'
|
||||||
|
'python-pillow'
|
||||||
|
'python-rich'
|
||||||
|
'python-textual'
|
||||||
|
)
|
||||||
|
makedepends=(
|
||||||
|
'python-build'
|
||||||
|
'python-hatchling'
|
||||||
|
'python-installer'
|
||||||
|
'python-wheel'
|
||||||
|
)
|
||||||
|
checkdepends=(
|
||||||
|
'python-pytest'
|
||||||
|
'python-pytest-asyncio'
|
||||||
|
)
|
||||||
|
optdepends=(
|
||||||
|
'ffmpeg: decode the stream for the spectrum analyser, and ffplay as the audio fallback'
|
||||||
|
'libpulse: capture the output monitor so the analyser follows what you hear (parec/pactl)'
|
||||||
|
'mpv: preferred audio backend'
|
||||||
|
'tmux: detect the outer terminal for kitty graphics passthrough inside tmux'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upstream does not tag releases yet, so the source is the archive of the
|
||||||
|
# commit that carries this version. Once a `v$pkgver` tag exists, switch to
|
||||||
|
#
|
||||||
|
# source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
|
||||||
|
#
|
||||||
|
_commit=941c7446384bf346e40d1438a7ce67939d9615df
|
||||||
|
source=("$pkgname-$pkgver.tar.gz::$url/archive/$_commit.tar.gz")
|
||||||
|
sha256sums=('8ac568676ef5ef8345441c28e4f8f5d61fb06d5225e566ac578acae18672c778')
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "$pkgname"
|
||||||
|
python -m build --wheel --no-isolation
|
||||||
|
}
|
||||||
|
|
||||||
|
check() {
|
||||||
|
cd "$pkgname"
|
||||||
|
# The suite is offline: catalogues are fixtures, mpv/parec are faked.
|
||||||
|
python -m pytest -q
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$pkgname"
|
||||||
|
python -m installer --destdir="$pkgdir" dist/*.whl
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# AUR packaging for beefy-player
|
||||||
|
|
||||||
|
An AUR repository holds exactly two tracked files, `PKGBUILD` and `.SRCINFO`.
|
||||||
|
This directory is the staging area for both; the rest of the project is
|
||||||
|
irrelevant to the AUR.
|
||||||
|
|
||||||
|
## Build it locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd aur
|
||||||
|
makepkg -si # build, run the test suite, install
|
||||||
|
```
|
||||||
|
|
||||||
|
`makepkg -o && makepkg -e` skips the download/checksum phase, and
|
||||||
|
`extra-x86_64-build` (devtools) builds in a clean chroot. The package builds
|
||||||
|
with `base-devel` plus the `makedepends`/`checkdepends` listed in the
|
||||||
|
`PKGBUILD`; every dependency comes from the official repositories, none from
|
||||||
|
the AUR.
|
||||||
|
|
||||||
|
## Publish to the AUR
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone ssh://aur@aur.archlinux.org/beefy-player.git /tmp/aur-beefy-player
|
||||||
|
cp PKGBUILD .SRCINFO /tmp/aur-beefy-player/
|
||||||
|
cd /tmp/aur-beefy-player
|
||||||
|
git add PKGBUILD .SRCINFO
|
||||||
|
git commit -m "Initial import: beefy-player 1.2.2-1"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
## Releasing a new version
|
||||||
|
|
||||||
|
1. Set `pkgver` in `PKGBUILD` and `pyproject.toml`, and reset `pkgrel=1`.
|
||||||
|
2. Point `_commit` at the commit carrying that version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rev-parse HEAD # or the tag, once one exists
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Refresh the checksum:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL "https://tildeforge.dev/beefcarver/beefy-player/archive/$_commit.tar.gz" | sha256sum
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Regenerate the metadata (on Arch, or inside a chroot):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
makepkg --printsrcinfo > .SRCINFO
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Push to the AUR with the conventional commit message:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -am "upgpkg: beefy-player <pkgver>-<pkgrel>"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why the source is a commit archive
|
||||||
|
|
||||||
|
Upstream publishes no tags or releases, so the checksum in `sha256sums`
|
||||||
|
belongs to the archive of a specific commit rather than of a `v$pkgver` tag.
|
||||||
|
Forge archives of a commit are deterministic, so the checksum stays stable.
|
||||||
|
Once `v$pkgver` is tagged, replace the `source` line with
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Packaging notes
|
||||||
|
|
||||||
|
- The wheel is built with `hatchling`; `theme.tcss` is force-included by
|
||||||
|
`pyproject.toml`, so it ends up in `site-packages/beefy_player/`.
|
||||||
|
- `check()` runs the real test suite. It is offline by design (station
|
||||||
|
catalogues are fixtures and `mpv`/`parec` are faked), which makes it safe in
|
||||||
|
a build chroot with no network.
|
||||||
|
- `mpv`, `ffmpeg`, `libpulse` (`parec`/`pactl`) and `tmux` are `optdepends`,
|
||||||
|
not `depends`: the player degrades gracefully without them and says so in
|
||||||
|
the UI. Users who want sound need `mpv` (or `ffmpeg` for `ffplay`) and, for
|
||||||
|
the output-synced spectrum, `libpulse`.
|
||||||
|
- `license=('WTFPL')` is an SPDX identifier shipped by Arch's `licenses`
|
||||||
|
package.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Beefy Player — a flashy terminal player for internet radio."""
|
||||||
|
|
||||||
|
__version__ = "1.2.2"
|
||||||
@@ -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,
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -228,22 +230,38 @@ async def fetch(client: httpx.AsyncClient, url: str) -> bytes | None:
|
|||||||
|
|
||||||
|
|
||||||
def fit_image(data: bytes, width: int, height: int):
|
def fit_image(data: bytes, width: int, height: int):
|
||||||
"""Decode and center-crop an image to exactly ``width`` x ``height`` cells."""
|
"""Decode and letterbox an image into exactly ``width`` x ``height`` cells.
|
||||||
|
|
||||||
|
Scale to fit, never crop. Square album art fills the box either way, but a
|
||||||
|
wide station wordmark (Bassdrive's logo is 3:1) used to be centre-cropped
|
||||||
|
down to a zoomed slice of its middle. The padding stays transparent, so
|
||||||
|
the mosaic behind the artwork shows through instead of a black bar.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
from PIL import Image, ImageOps
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
with Image.open(BytesIO(data)) as source:
|
with Image.open(BytesIO(data)) as source:
|
||||||
|
# A PNG with transparency is drawn *over* the mosaic, which reads
|
||||||
|
# as noise behind a logo; composite those onto black instead.
|
||||||
|
has_alpha = source.mode in ("RGBA", "LA", "PA", "RGBa", "La") or (
|
||||||
|
"transparency" in source.info
|
||||||
|
)
|
||||||
image = source.convert("RGBA")
|
image = source.convert("RGBA")
|
||||||
cell_width, cell_height = cell_size()
|
cell_width, cell_height = cell_size()
|
||||||
target = (
|
target = (
|
||||||
max(1, width * cell_width),
|
max(1, width * cell_width),
|
||||||
max(1, height * cell_height),
|
max(1, height * cell_height),
|
||||||
)
|
)
|
||||||
return ImageOps.fit(
|
fitted = ImageOps.contain(image, target, method=Image.Resampling.LANCZOS)
|
||||||
image,
|
background = (0, 0, 0, 255) if has_alpha else (0, 0, 0, 0)
|
||||||
target,
|
canvas = Image.new("RGBA", target, background)
|
||||||
method=Image.Resampling.LANCZOS,
|
canvas.alpha_composite(
|
||||||
centering=(0.5, 0.5),
|
fitted,
|
||||||
|
(
|
||||||
|
(target[0] - fitted.width) // 2,
|
||||||
|
(target[1] - fitted.height) // 2,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
return canvas
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""Paths, persistent settings and tiny cache helpers.
|
||||||
|
|
||||||
|
Everything lives in the usual XDG places:
|
||||||
|
|
||||||
|
~/.config/beefy-player/config.json favourites, volume, station
|
||||||
|
~/.cache/beefy-player/channels.json last good station list (SomaFM)
|
||||||
|
~/.cache/beefy-player/channels-<id>.json ... for every other catalogue
|
||||||
|
~/.cache/beefy-player/schedule-<id>.json last good broadcast schedule
|
||||||
|
~/.cache/beefy-player/playlists.json resolved .pls -> stream URLs
|
||||||
|
~/.cache/beefy-player/songs/<id>.json last good song history
|
||||||
|
~/.cache/beefy-player/artwork/<sha1> cached album/channel artwork
|
||||||
|
|
||||||
|
Settings from the pre-rename ``somafm-tui`` directory are copied over once, so
|
||||||
|
upgrading does not lose favourites or volume.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
|
from .palettes import DEFAULT_PALETTE, PALETTES
|
||||||
|
|
||||||
|
#: 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:
|
||||||
|
value = os.environ.get(env_var)
|
||||||
|
if value:
|
||||||
|
return Path(value).expanduser()
|
||||||
|
return Path.home() / fallback
|
||||||
|
|
||||||
|
|
||||||
|
def env_var(suffix: str) -> str:
|
||||||
|
"""``BEEFY_<suffix>``, falling back to the legacy ``SOMAFM_<suffix>``."""
|
||||||
|
value = os.environ.get(ENV_PREFIX + suffix)
|
||||||
|
if value is None:
|
||||||
|
value = os.environ.get(LEGACY_ENV_PREFIX + suffix)
|
||||||
|
return (value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG_DIR = _xdg("XDG_CONFIG_HOME", ".config") / APP_NAME
|
||||||
|
CACHE_DIR = _xdg("XDG_CACHE_HOME", ".cache") / APP_NAME
|
||||||
|
RUNTIME_DIR = Path(os.environ.get("XDG_RUNTIME_DIR") or "/tmp") / APP_NAME
|
||||||
|
|
||||||
|
CONFIG_PATH = CONFIG_DIR / "config.json"
|
||||||
|
CHANNELS_CACHE = CACHE_DIR / "channels.json"
|
||||||
|
PLAYLIST_CACHE = CACHE_DIR / "playlists.json"
|
||||||
|
SONGS_CACHE_DIR = CACHE_DIR / "songs"
|
||||||
|
ARTWORK_CACHE_DIR = CACHE_DIR / "artwork"
|
||||||
|
|
||||||
|
#: Pre-rename location, read once by :meth:`Config.load`.
|
||||||
|
LEGACY_CONFIG_PATH = _xdg("XDG_CONFIG_HOME", ".config") / LEGACY_APP_NAME / "config.json"
|
||||||
|
|
||||||
|
VISUALIZERS = ("both", "bars", "wave", "waterfall", "radial", "braille")
|
||||||
|
|
||||||
|
#: Colour palettes the UI can draw with (see :mod:`beefy_player.palettes`).
|
||||||
|
PALETTE_NAMES = tuple(PALETTES)
|
||||||
|
|
||||||
|
|
||||||
|
def channels_cache_path(provider_id: str) -> Path:
|
||||||
|
"""Where a catalogue caches its last good station list.
|
||||||
|
|
||||||
|
SomaFM keeps the historical ``channels.json``; every other provider gets
|
||||||
|
its own file, so two catalogues can never overwrite each other's cache.
|
||||||
|
"""
|
||||||
|
slug = re.sub(r"[^a-z0-9_-]", "_", provider_id.strip().lower())
|
||||||
|
if not slug or slug == "somafm":
|
||||||
|
return CHANNELS_CACHE
|
||||||
|
return CACHE_DIR / f"channels-{slug}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_cache_path(provider_id: str) -> Path:
|
||||||
|
"""Where a catalogue caches its last good broadcast schedule."""
|
||||||
|
slug = re.sub(r"[^a-z0-9_-]", "_", provider_id.strip().lower()) or "provider"
|
||||||
|
return CACHE_DIR / f"schedule-{slug}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dirs() -> None:
|
||||||
|
for directory in (CONFIG_DIR, CACHE_DIR, SONGS_CACHE_DIR):
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_write_text(path: Path, text: str) -> None:
|
||||||
|
"""Write ``text`` to ``path`` without ever leaving a half-written file."""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_name(f".{path.name}.tmp")
|
||||||
|
tmp.write_text(text, encoding="utf-8")
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(path: Path) -> Any:
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(path: Path, payload: Any) -> None:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
atomic_write_text(path, json.dumps(payload, indent=2, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_legacy_state() -> None:
|
||||||
|
"""Copy a pre-rename config file into place, once.
|
||||||
|
|
||||||
|
Favourites, volume and the last station are the only state worth keeping;
|
||||||
|
caches are rebuilt on the next run.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if CONFIG_PATH.exists() or not LEGACY_CONFIG_PATH.is_file():
|
||||||
|
return
|
||||||
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copyfile(LEGACY_CONFIG_PATH, CONFIG_PATH)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Config:
|
||||||
|
favorites: list[str] = field(default_factory=list)
|
||||||
|
last_station: str = "groovesalad"
|
||||||
|
#: Which station each catalogue was last tuned to, keyed by provider slug,
|
||||||
|
#: so switching back lands where you left off.
|
||||||
|
last_stations: dict[str, str] = field(default_factory=dict)
|
||||||
|
volume: int = 70
|
||||||
|
visualizer: str = "both"
|
||||||
|
#: Colour palette name — spectrum, ocean, ember, …
|
||||||
|
palette: str = DEFAULT_PALETTE
|
||||||
|
autoplay: bool = False
|
||||||
|
#: Which catalogue to use by default (see :mod:`beefy_player.providers`).
|
||||||
|
provider: str = "somafm"
|
||||||
|
#: Publish what is playing to the desktop media session (MPRIS on Linux,
|
||||||
|
#: macOS Now Playing via mpv) and honour the media keys. The JSON key is
|
||||||
|
#: spelled ``mpris`` for backwards compatibility.
|
||||||
|
mpris: bool = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls) -> Config:
|
||||||
|
migrate_legacy_state()
|
||||||
|
raw = read_json(CONFIG_PATH)
|
||||||
|
config = cls()
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return config
|
||||||
|
|
||||||
|
favorites = raw.get("favorites")
|
||||||
|
if isinstance(favorites, list):
|
||||||
|
config.favorites = [str(item) for item in favorites if isinstance(item, str)]
|
||||||
|
|
||||||
|
last_station = raw.get("last_station")
|
||||||
|
if isinstance(last_station, str) and last_station:
|
||||||
|
config.last_station = last_station
|
||||||
|
|
||||||
|
last_stations = raw.get("last_stations")
|
||||||
|
if isinstance(last_stations, dict):
|
||||||
|
config.last_stations = {
|
||||||
|
str(key): str(value)
|
||||||
|
for key, value in last_stations.items()
|
||||||
|
if isinstance(value, str) and value
|
||||||
|
}
|
||||||
|
|
||||||
|
volume = raw.get("volume")
|
||||||
|
if isinstance(volume, (int, float)):
|
||||||
|
config.volume = max(0, min(100, int(volume)))
|
||||||
|
|
||||||
|
visualizer = raw.get("visualizer")
|
||||||
|
if visualizer in VISUALIZERS:
|
||||||
|
config.visualizer = visualizer
|
||||||
|
|
||||||
|
palette = raw.get("palette")
|
||||||
|
if isinstance(palette, str) and palette in PALETTE_NAMES:
|
||||||
|
config.palette = palette
|
||||||
|
|
||||||
|
provider = raw.get("provider")
|
||||||
|
if isinstance(provider, str) and provider.strip():
|
||||||
|
config.provider = provider.strip().lower()
|
||||||
|
|
||||||
|
if isinstance(raw.get("autoplay"), bool):
|
||||||
|
config.autoplay = raw["autoplay"]
|
||||||
|
if isinstance(raw.get("mpris"), bool):
|
||||||
|
config.mpris = raw["mpris"]
|
||||||
|
return config
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
try:
|
||||||
|
ensure_dirs()
|
||||||
|
atomic_write_text(
|
||||||
|
CONFIG_PATH, json.dumps(asdict(self), indent=2, ensure_ascii=False)
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def last_station_for(self, provider_id: str) -> str:
|
||||||
|
"""The station to re-tune for a catalogue (old configs have one only)."""
|
||||||
|
return self.last_stations.get(provider_id) or self.last_station
|
||||||
|
|
||||||
|
def is_favorite(self, station_id: str) -> bool:
|
||||||
|
return station_id in self.favorites
|
||||||
|
|
||||||
|
def toggle_favorite(self, station_id: str) -> bool:
|
||||||
|
if station_id in self.favorites:
|
||||||
|
self.favorites.remove(station_id)
|
||||||
|
return False
|
||||||
|
self.favorites.append(station_id)
|
||||||
|
return True
|
||||||
@@ -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,210 @@
|
|||||||
|
"""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 Broadcast:
|
||||||
|
"""One entry in a catalogue's schedule: a show, a DJ slot, a stream."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
dj: str = ""
|
||||||
|
#: Where the broadcast comes from ("Poznan, Poland"), when the feed says.
|
||||||
|
location: str = ""
|
||||||
|
start: datetime | None = None
|
||||||
|
end: datetime | None = None
|
||||||
|
#: True for the broadcast that is on air right now.
|
||||||
|
live: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self) -> str:
|
||||||
|
return self.name.strip() or self.dj.strip() or "untitled broadcast"
|
||||||
|
|
||||||
|
def key(self) -> str:
|
||||||
|
return f"{self.name}\x1f{self.start.isoformat() if self.start else ''}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Station:
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
description: str = ""
|
||||||
|
genre: str = ""
|
||||||
|
dj: str = ""
|
||||||
|
#: Who is presenting right now when that is more than a DJ name —
|
||||||
|
#: ``"auto dj · library rotation"`` for a station between live shows.
|
||||||
|
presenter: 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"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def presenter_label(self) -> str:
|
||||||
|
"""What the ON AIR metadata line says about who is on the desk."""
|
||||||
|
if self.presenter.strip():
|
||||||
|
return self.presenter.strip()
|
||||||
|
if self.dj.strip():
|
||||||
|
return f"dj {self.dj.strip()}"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
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} · {playlist_bitrate(first)}k"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# small, provider-agnostic parsers
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def playlist_bitrate(playlist: dict[str, str]) -> int:
|
||||||
|
"""The bitrate a catalogue declared, else the best guess from the URL."""
|
||||||
|
declared = to_int(playlist.get("bitrate"))
|
||||||
|
return declared or guess_bitrate(playlist.get("url", ""))
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""The colour palettes everything in the UI draws from.
|
||||||
|
|
||||||
|
Every colour in Beefy Player is a stock ANSI palette *name*, never RGB, so the
|
||||||
|
TUI adopts whatever colours the terminal is configured with. A palette here is
|
||||||
|
therefore a short ladder of those names — the "gradient" is a quantised walk
|
||||||
|
from the quiet end to the hot end, not an interpolation.
|
||||||
|
|
||||||
|
The active palette drives the spectrum analyser, the album-art mosaic, the
|
||||||
|
provider wordmarks and the pane corners; `c` cycles through them and the
|
||||||
|
choice is remembered in the config file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
#: Dark → hot ladders of ANSI names. Sixteen of them: seven hue gradients, a
|
||||||
|
#: few near-monochromes, and the original spectrum.
|
||||||
|
PALETTES: dict[str, tuple[str, ...]] = {
|
||||||
|
"spectrum": (
|
||||||
|
"cyan",
|
||||||
|
"blue",
|
||||||
|
"magenta",
|
||||||
|
"bright_magenta",
|
||||||
|
"yellow",
|
||||||
|
"bright_yellow",
|
||||||
|
"red",
|
||||||
|
),
|
||||||
|
"rainbow": (
|
||||||
|
"red",
|
||||||
|
"bright_yellow",
|
||||||
|
"bright_green",
|
||||||
|
"bright_cyan",
|
||||||
|
"bright_blue",
|
||||||
|
"bright_magenta",
|
||||||
|
),
|
||||||
|
"ocean": (
|
||||||
|
"bright_black",
|
||||||
|
"blue",
|
||||||
|
"bright_blue",
|
||||||
|
"cyan",
|
||||||
|
"bright_cyan",
|
||||||
|
"bright_white",
|
||||||
|
),
|
||||||
|
"ember": (
|
||||||
|
"bright_black",
|
||||||
|
"red",
|
||||||
|
"bright_red",
|
||||||
|
"yellow",
|
||||||
|
"bright_yellow",
|
||||||
|
"bright_white",
|
||||||
|
),
|
||||||
|
"mint": (
|
||||||
|
"bright_black",
|
||||||
|
"green",
|
||||||
|
"bright_green",
|
||||||
|
"cyan",
|
||||||
|
"bright_cyan",
|
||||||
|
"bright_white",
|
||||||
|
),
|
||||||
|
"sunset": ("magenta", "bright_magenta", "red", "bright_red", "yellow"),
|
||||||
|
"violet": ("blue", "bright_blue", "magenta", "bright_magenta", "bright_white"),
|
||||||
|
"acid": ("bright_black", "green", "bright_green", "yellow", "bright_yellow"),
|
||||||
|
"neon": (
|
||||||
|
"bright_magenta",
|
||||||
|
"bright_red",
|
||||||
|
"bright_yellow",
|
||||||
|
"bright_green",
|
||||||
|
"bright_cyan",
|
||||||
|
),
|
||||||
|
"sand": ("bright_black", "yellow", "bright_yellow", "white", "bright_white"),
|
||||||
|
"blood": ("bright_black", "red", "bright_red", "bright_magenta"),
|
||||||
|
"forest": ("bright_black", "green", "bright_green", "yellow", "bright_white"),
|
||||||
|
"ice": ("bright_white", "bright_cyan", "cyan", "bright_blue", "blue"),
|
||||||
|
"steel": ("bright_black", "white", "bright_white", "bright_cyan"),
|
||||||
|
"candy": ("magenta", "bright_magenta", "bright_cyan", "bright_yellow"),
|
||||||
|
"mono": ("bright_black", "white", "bright_white"),
|
||||||
|
}
|
||||||
|
|
||||||
|
#: The stock names from dark to bright. These are xterm's default values in
|
||||||
|
#: Rec. 601 luma order — the closest thing to a stock answer. A theme may
|
||||||
|
#: disagree about the exact numbers, but the ordering is what the frame fades
|
||||||
|
#: need: a ladder is a *hue* walk, so where it ends says nothing about which
|
||||||
|
#: end is brighter.
|
||||||
|
BRIGHTNESS: tuple[str, ...] = (
|
||||||
|
"black",
|
||||||
|
"blue",
|
||||||
|
"red",
|
||||||
|
"bright_red",
|
||||||
|
"magenta",
|
||||||
|
"bright_magenta",
|
||||||
|
"bright_blue",
|
||||||
|
"green",
|
||||||
|
"bright_black",
|
||||||
|
"cyan",
|
||||||
|
"bright_green",
|
||||||
|
"bright_cyan",
|
||||||
|
"yellow",
|
||||||
|
"white",
|
||||||
|
"bright_yellow",
|
||||||
|
"bright_white",
|
||||||
|
)
|
||||||
|
|
||||||
|
_RANK = {name: rank for rank, name in enumerate(BRIGHTNESS)}
|
||||||
|
|
||||||
|
#: Cycle order for the palette key.
|
||||||
|
PALETTE_ORDER: tuple[str, ...] = tuple(PALETTES)
|
||||||
|
|
||||||
|
#: What you get until you press `c`.
|
||||||
|
DEFAULT_PALETTE = "spectrum"
|
||||||
|
|
||||||
|
|
||||||
|
def palette_stops(name: str | None) -> tuple[str, ...]:
|
||||||
|
"""The ladder for ``name``, falling back to the default palette."""
|
||||||
|
key = (name or "").strip().lower()
|
||||||
|
return PALETTES.get(key, PALETTES[DEFAULT_PALETTE])
|
||||||
|
|
||||||
|
|
||||||
|
def brightness(colour: str) -> int:
|
||||||
|
"""How bright a stock name is: 0 (black) to 15 (bright white)."""
|
||||||
|
return _RANK.get(colour.strip().lower(), 0)
|
||||||
@@ -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,86 @@
|
|||||||
|
"""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 .bassdrive import BassdriveProvider
|
||||||
|
from .community import (
|
||||||
|
AnonradioProvider,
|
||||||
|
CashmereProvider,
|
||||||
|
DatafruitsProvider,
|
||||||
|
DublabProvider,
|
||||||
|
LotProvider,
|
||||||
|
LylProvider,
|
||||||
|
RefugeProvider,
|
||||||
|
RinseProvider,
|
||||||
|
)
|
||||||
|
from .radiobrowser import RadioBrowserProvider
|
||||||
|
from .radioparadise import RadioParadiseProvider
|
||||||
|
from .somafm import SomaFMProvider
|
||||||
|
from .tilderadio import TilderadioProvider
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DEFAULT_PROVIDER",
|
||||||
|
"PROVIDERS",
|
||||||
|
"RadioProvider",
|
||||||
|
"available_providers",
|
||||||
|
"get_provider",
|
||||||
|
]
|
||||||
|
|
||||||
|
#: Every catalogue the app can talk to, keyed by its slug. The picker, the
|
||||||
|
#: help screen and ``--list`` all read this order: alphabetical by name.
|
||||||
|
PROVIDERS: dict[str, RadioProvider] = {
|
||||||
|
provider.id: provider
|
||||||
|
for provider in sorted(
|
||||||
|
(
|
||||||
|
SomaFMProvider(),
|
||||||
|
TilderadioProvider(),
|
||||||
|
BassdriveProvider(),
|
||||||
|
RadioBrowserProvider(),
|
||||||
|
RadioParadiseProvider(),
|
||||||
|
DublabProvider(),
|
||||||
|
RinseProvider(),
|
||||||
|
RefugeProvider(),
|
||||||
|
LylProvider(),
|
||||||
|
LotProvider(),
|
||||||
|
CashmereProvider(),
|
||||||
|
DatafruitsProvider(),
|
||||||
|
AnonradioProvider(),
|
||||||
|
),
|
||||||
|
key=lambda provider: provider.name.casefold(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#: 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,338 @@
|
|||||||
|
"""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 dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .. import config
|
||||||
|
from ..models import Broadcast, Song, Station
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PLAYLIST_TTL",
|
||||||
|
"RadioProvider",
|
||||||
|
"StationPage",
|
||||||
|
"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
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class StationPage:
|
||||||
|
"""One page of a large catalogue, in the catalogue's own order."""
|
||||||
|
|
||||||
|
stations: list[Station]
|
||||||
|
#: Offset to request next, or ``None`` when the catalogue is exhausted.
|
||||||
|
next_offset: int | None = 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 = ""
|
||||||
|
|
||||||
|
# -- header hero ----------------------------------------------------- #
|
||||||
|
|
||||||
|
#: Rows of header art drawn at the top left (rich markup is allowed).
|
||||||
|
hero: tuple[str, ...] = ()
|
||||||
|
#: Paint :attr:`hero` with the app's spectrum gradient (a wordmark) instead
|
||||||
|
#: of the colours the art carries itself (a picture).
|
||||||
|
hero_gradient: bool = False
|
||||||
|
#: Short lines printed beside the hero, top to bottom.
|
||||||
|
hero_lines: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
# -- left pane ------------------------------------------------------- #
|
||||||
|
|
||||||
|
#: Show a broadcast schedule in the left pane instead of a station list.
|
||||||
|
#: Set by single-stream catalogues, where a list of one station is a waste
|
||||||
|
#: of the pane and "what is on, what is next" is the useful view.
|
||||||
|
has_schedule: bool = False
|
||||||
|
|
||||||
|
#: Look the current track up on Bandcamp and offer a link when it is found.
|
||||||
|
#: Off for catalogues whose rotation is unlikely to be there (and for
|
||||||
|
#: stations that would rather not be scraped).
|
||||||
|
bandcamp: bool = True
|
||||||
|
|
||||||
|
#: The catalogue can search its whole directory on the server. When true,
|
||||||
|
#: the app calls :meth:`search_stations` as the filter changes instead of
|
||||||
|
#: only filtering the (possibly small) list from :meth:`fetch_stations`.
|
||||||
|
server_search: bool = False
|
||||||
|
|
||||||
|
#: The catalogue is larger than one page. When true, the app fetches the
|
||||||
|
#: first page with :meth:`fetch_station_page` and asks for the next one as
|
||||||
|
#: the station list nears its end.
|
||||||
|
paginated: bool = False
|
||||||
|
|
||||||
|
#: Keep the catalogue's own ordering (popularity, schedule, ...) instead of
|
||||||
|
#: sorting the station list alphabetically.
|
||||||
|
preserve_order: bool = False
|
||||||
|
|
||||||
|
# -- 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."""
|
||||||
|
|
||||||
|
async def search_stations(
|
||||||
|
self, client: httpx.AsyncClient, query: str
|
||||||
|
) -> list[Station]:
|
||||||
|
"""Search the catalogue for *query*.
|
||||||
|
|
||||||
|
Only called when :attr:`server_search` is true. Providers that search
|
||||||
|
locally leave this alone; the app filters the loaded list itself.
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def fetch_station_page(
|
||||||
|
self, client: httpx.AsyncClient, offset: int = 0
|
||||||
|
) -> StationPage:
|
||||||
|
"""One page of a paginated catalogue.
|
||||||
|
|
||||||
|
Only called when :attr:`paginated` is true. ``offset`` is the value
|
||||||
|
from the previous page's :attr:`StationPage.next_offset`.
|
||||||
|
"""
|
||||||
|
return StationPage(stations=[], next_offset=None)
|
||||||
|
|
||||||
|
async def fetch_stations_by_ids(
|
||||||
|
self, client: httpx.AsyncClient, station_ids: list[str]
|
||||||
|
) -> list[Station]:
|
||||||
|
"""Fetch specific stations by id.
|
||||||
|
|
||||||
|
Used for favourites that are not on the first page of a paginated
|
||||||
|
catalogue. Providers that return their whole catalogue at once can
|
||||||
|
leave this alone.
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
# -- 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 ``[]``."""
|
||||||
|
|
||||||
|
# -- schedule -------------------------------------------------------- #
|
||||||
|
|
||||||
|
async def fetch_schedule(self, client: httpx.AsyncClient) -> list[Broadcast]:
|
||||||
|
"""Upcoming broadcasts, soonest first (the live one, if any, first)."""
|
||||||
|
return []
|
||||||
|
|
||||||
|
def load_cached_schedule(self) -> list[Broadcast]:
|
||||||
|
"""The last good schedule from disk, or ``[]``."""
|
||||||
|
return []
|
||||||
|
|
||||||
|
# -- 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:
|
||||||
|
"""A playlist *we* resolve; HLS is handed to the player untouched."""
|
||||||
|
path = url.split("?", 1)[0].split("#", 1)[0].lower()
|
||||||
|
return path.endswith((".pls", ".m3u"))
|
||||||
|
|
||||||
|
|
||||||
|
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,76 @@
|
|||||||
|
"""Bassdrive: worldwide drum & bass, on air since the dial-up days.
|
||||||
|
|
||||||
|
One stream with three mounts, a schedule full of live DJ shows and a
|
||||||
|
``display-title`` that names the show rather than the track. Everything
|
||||||
|
Bassdrive-specific — the Icecast status schema, the homepage schedule grid, the
|
||||||
|
rolling show history and the bundled fallback — lives in this package; the app
|
||||||
|
only sees :class:`BassdriveProvider`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ... import config
|
||||||
|
from ...models import Broadcast, Song, Station
|
||||||
|
from ..base import RadioProvider
|
||||||
|
from . import api, fallback
|
||||||
|
from . import hero as hero_art
|
||||||
|
|
||||||
|
__all__ = ["BassdriveProvider"]
|
||||||
|
|
||||||
|
|
||||||
|
class BassdriveProvider(RadioProvider):
|
||||||
|
id = "bassdrive"
|
||||||
|
name = "Bassdrive"
|
||||||
|
homepage = "bassdrive.com"
|
||||||
|
brand = "BASSDRIVE"
|
||||||
|
tagline = "worldwide drum & bass · live shows"
|
||||||
|
station_source = "bassdrive.com + icecast status"
|
||||||
|
history_source = "icecast status (rolling)"
|
||||||
|
|
||||||
|
# The wordmark in the station's own two blues, in the shared half-block
|
||||||
|
# font; the caption is the station's own description.
|
||||||
|
hero = hero_art.HERO
|
||||||
|
hero_lines = (
|
||||||
|
"BASSDRIVE",
|
||||||
|
"WORLDWIDE DRUM & BASS",
|
||||||
|
"LIVE SHOWS 24/7",
|
||||||
|
)
|
||||||
|
|
||||||
|
# One stream and a schedule full of DJ slots: the left pane is the schedule.
|
||||||
|
has_schedule = True
|
||||||
|
# DJ-mix station: the rotation is not a Bandcamp catalogue.
|
||||||
|
bandcamp = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_agent(self) -> str:
|
||||||
|
return f"{config.USER_AGENT}; +https://bassdrive.com"
|
||||||
|
|
||||||
|
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
|
||||||
|
return [await api.fetch_station(client)]
|
||||||
|
|
||||||
|
def load_cached_stations(self) -> list[Station]:
|
||||||
|
return api.load_cached_station()
|
||||||
|
|
||||||
|
def fallback_stations(self) -> list[Station]:
|
||||||
|
return fallback.fallback_stations()
|
||||||
|
|
||||||
|
async def fetch_history(
|
||||||
|
self, client: httpx.AsyncClient, station_id: str
|
||||||
|
) -> list[Song]:
|
||||||
|
return await api.fetch_songs(client, station_id)
|
||||||
|
|
||||||
|
def load_cached_history(self, station_id: str) -> list[Song]:
|
||||||
|
return api.load_cached_songs(station_id)
|
||||||
|
|
||||||
|
async def fetch_schedule(
|
||||||
|
self, client: httpx.AsyncClient
|
||||||
|
) -> list[Broadcast]:
|
||||||
|
return await api.fetch_schedule(client)
|
||||||
|
|
||||||
|
def load_cached_schedule(self) -> list[Broadcast]:
|
||||||
|
return api.load_cached_schedule()
|
||||||
|
|
||||||
|
def fallback_urls(self, station: Station) -> list[str]:
|
||||||
|
return fallback.known_stream_urls()
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
"""Bassdrive's public endpoints, and nothing but.
|
||||||
|
|
||||||
|
* ``https://ice.bassdrive.net/status-json.xsl`` -> Icecast status: every mount
|
||||||
|
(mp3/aac+, bitrates), the listener counts and the current ``display-title``,
|
||||||
|
which for a DJ-mix stream is the *show*: ``"Ben XO :: LIVE :: Night Shift
|
||||||
|
Over"``.
|
||||||
|
* ``http://www.bassdrive.com/`` -> the homepage carries the whole week's
|
||||||
|
schedule, every slot stamped with an absolute ``<local-time datetime=...>``.
|
||||||
|
|
||||||
|
A DJ-mix station has no track history: Icecast reports one title and forgets
|
||||||
|
it. So "now playing" is the show (mapped onto ``Station.dj``/``presenter`` and
|
||||||
|
a :class:`Song`), and the history is a rolling record of the shows we have
|
||||||
|
heard since the app started — the unit a listener actually cares about here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import html as html_module
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ... import config
|
||||||
|
from ...models import Broadcast, Song, Station, parse_date, to_int
|
||||||
|
from ..base import fetch_json
|
||||||
|
|
||||||
|
PROVIDER_ID = "bassdrive"
|
||||||
|
STATUS_URL = "https://ice.bassdrive.net/status-json.xsl"
|
||||||
|
SCHEDULE_URL = "https://www.bassdrive.com/"
|
||||||
|
|
||||||
|
DEFAULT_STATION_ID = "bassdrive"
|
||||||
|
STATION_IMAGE = "https://www.bassdrive.com/images/logo.png"
|
||||||
|
DESCRIPTION = "worldwide drum & bass radio — live DJ shows around the clock"
|
||||||
|
GENRE = "drum & bass|jungle"
|
||||||
|
|
||||||
|
WEEK = timedelta(days=7)
|
||||||
|
#: Upcoming slots kept in the pane (a week is plenty for a schedule).
|
||||||
|
SCHEDULE_LIMIT = 40
|
||||||
|
#: Shows remembered in LAST PLAYED.
|
||||||
|
HISTORY_LIMIT = 20
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# status-json -> Station / Song
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _sources(payload: Any) -> list[dict[str, Any]]:
|
||||||
|
stats = payload.get("icestats") if isinstance(payload, dict) else None
|
||||||
|
source = stats.get("source") if isinstance(stats, dict) else None
|
||||||
|
if isinstance(source, dict):
|
||||||
|
return [source]
|
||||||
|
if isinstance(source, list):
|
||||||
|
return [entry for entry in source if isinstance(entry, dict)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _quality(bitrate: int) -> str:
|
||||||
|
if bitrate >= 128:
|
||||||
|
return "highest"
|
||||||
|
if bitrate >= 48:
|
||||||
|
return "high"
|
||||||
|
return "low" if bitrate else ""
|
||||||
|
|
||||||
|
|
||||||
|
def playlists_from_status(payload: Any) -> list[dict[str, str]]:
|
||||||
|
playlists: list[dict[str, str]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for source in _sources(payload):
|
||||||
|
url = source.get("listenurl")
|
||||||
|
if not isinstance(url, str) or not url.startswith(("http://", "https://")):
|
||||||
|
continue
|
||||||
|
if url in seen:
|
||||||
|
continue
|
||||||
|
seen.add(url)
|
||||||
|
bitrate = to_int(source.get("bitrate"))
|
||||||
|
kind = str(source.get("server_type") or "").lower()
|
||||||
|
fmt = "mp3" if "mpeg" in kind else "aacp" if "aac" in kind else ""
|
||||||
|
playlists.append(
|
||||||
|
{
|
||||||
|
"url": url,
|
||||||
|
"format": fmt,
|
||||||
|
"quality": _quality(bitrate),
|
||||||
|
"bitrate": str(bitrate) if bitrate else "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return playlists
|
||||||
|
|
||||||
|
|
||||||
|
def status_listeners(payload: Any) -> int:
|
||||||
|
"""Mounts split the audience, so the station's figure is their sum."""
|
||||||
|
return sum(to_int(source.get("listeners")) for source in _sources(payload))
|
||||||
|
|
||||||
|
|
||||||
|
def display_title(payload: Any) -> str:
|
||||||
|
for source in _sources(payload):
|
||||||
|
for key in ("display-title", "title"):
|
||||||
|
value = source.get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def split_title(raw: str) -> tuple[str, str, bool]:
|
||||||
|
"""``"Ben XO :: LIVE :: Night Shift Over [promo]"`` -> DJ, show, live.
|
||||||
|
|
||||||
|
Bassdrive's title convention is ``DJ :: LIVE :: Show``, with promotional
|
||||||
|
brackets tacked on the end — but it is not enforced: plenty of shows go out
|
||||||
|
as ``"Sohl Patrol LIVE"``, so a trailing ``LIVE`` counts too. Anything
|
||||||
|
unexpected degrades to "the whole string is the show".
|
||||||
|
"""
|
||||||
|
cleaned = re.sub(r"\[[^\]]*\]", " ", raw)
|
||||||
|
parts = [part.strip() for part in cleaned.split("::") if part.strip()]
|
||||||
|
live = any(part.casefold() == "live" for part in parts)
|
||||||
|
parts = [part for part in parts if part.casefold() != "live"]
|
||||||
|
|
||||||
|
if parts:
|
||||||
|
trailing = re.search(r"(?i)\s+live$", parts[-1])
|
||||||
|
if trailing:
|
||||||
|
live = True
|
||||||
|
trimmed = parts[-1][: trailing.start()].strip()
|
||||||
|
parts = parts[:-1] + ([trimmed] if trimmed else [])
|
||||||
|
|
||||||
|
if not parts:
|
||||||
|
return "", raw.strip(), live
|
||||||
|
if len(parts) == 1:
|
||||||
|
return "", parts[0], live
|
||||||
|
return parts[0], parts[-1], live
|
||||||
|
|
||||||
|
|
||||||
|
def song_from_title(raw: str) -> Song | None:
|
||||||
|
dj, show, _live = split_title(raw)
|
||||||
|
if not (dj or show):
|
||||||
|
return None
|
||||||
|
# With no DJ in the title the station stands in as the "artist": a panel
|
||||||
|
# that says "Unknown artist" for every show would look broken.
|
||||||
|
return Song(artist=dj or "Bassdrive", title=show)
|
||||||
|
|
||||||
|
|
||||||
|
def _same_show(entry: Broadcast, show: str) -> bool:
|
||||||
|
return bool(show) and entry.name.strip().casefold() == show.strip().casefold()
|
||||||
|
|
||||||
|
|
||||||
|
def _slot_for(schedule: Iterable[Broadcast], show: str) -> Broadcast | None:
|
||||||
|
if not show:
|
||||||
|
return None
|
||||||
|
return next((entry for entry in schedule if _same_show(entry, show)), None)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_now(
|
||||||
|
payload: Any,
|
||||||
|
schedule: Iterable[Broadcast] = (),
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> tuple[str, str, bool, Broadcast | None]:
|
||||||
|
"""Who is on, according to the stream *and* the schedule.
|
||||||
|
|
||||||
|
The display-title often forgets the ``LIVE`` marker; a show whose slot
|
||||||
|
covers now counts as live even when the title is just its name.
|
||||||
|
"""
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
schedule = list(schedule)
|
||||||
|
dj, show, live = split_title(display_title(payload))
|
||||||
|
slot = _slot_for(schedule, show)
|
||||||
|
if (
|
||||||
|
slot is not None
|
||||||
|
and slot.start is not None
|
||||||
|
and slot.end is not None
|
||||||
|
and slot.start <= now < slot.end
|
||||||
|
):
|
||||||
|
live = True
|
||||||
|
return dj, show, live, slot
|
||||||
|
|
||||||
|
|
||||||
|
def station_from_status(
|
||||||
|
payload: Any, schedule: Iterable[Broadcast] = ()
|
||||||
|
) -> Station | None:
|
||||||
|
playlists = playlists_from_status(payload)
|
||||||
|
raw = display_title(payload)
|
||||||
|
if not playlists and not raw:
|
||||||
|
return None
|
||||||
|
|
||||||
|
dj, show, live, slot = resolve_now(payload, schedule)
|
||||||
|
location = slot.location if slot is not None else ""
|
||||||
|
if live:
|
||||||
|
presenter = f"live from {location}" if location else "live now"
|
||||||
|
else:
|
||||||
|
presenter = "rotation · drum & bass"
|
||||||
|
|
||||||
|
station = Station(
|
||||||
|
id=DEFAULT_STATION_ID,
|
||||||
|
title="Bassdrive",
|
||||||
|
description=DESCRIPTION,
|
||||||
|
genre=GENRE,
|
||||||
|
dj=dj,
|
||||||
|
presenter=presenter,
|
||||||
|
image=STATION_IMAGE,
|
||||||
|
listeners=status_listeners(payload),
|
||||||
|
playlists=playlists,
|
||||||
|
provider=PROVIDER_ID,
|
||||||
|
)
|
||||||
|
if show:
|
||||||
|
# The "artist" of a show-based stream is the station itself.
|
||||||
|
station.now_playing = Song(artist=dj or "Bassdrive", title=show)
|
||||||
|
station.last_playing = raw
|
||||||
|
return station
|
||||||
|
|
||||||
|
|
||||||
|
def live_broadcast(
|
||||||
|
payload: Any,
|
||||||
|
schedule: Iterable[Broadcast],
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> Broadcast | None:
|
||||||
|
dj, show, live, slot = resolve_now(payload, schedule, now)
|
||||||
|
if not live or not show:
|
||||||
|
return None
|
||||||
|
if slot is not None:
|
||||||
|
return Broadcast(
|
||||||
|
name=show,
|
||||||
|
dj=dj,
|
||||||
|
location=slot.location,
|
||||||
|
start=slot.start,
|
||||||
|
end=slot.end,
|
||||||
|
live=True,
|
||||||
|
)
|
||||||
|
return Broadcast(name=show, dj=dj, live=True)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# the homepage schedule
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
_SPAN_LOCATION = re.compile(r'<span class="location">(.*?)</span>', re.S)
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: str) -> str:
|
||||||
|
return re.sub(r"\s+", " ", html_module.unescape(re.sub(r"<[^>]+>", " ", value))).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_schedule(page: str) -> list[Broadcast]:
|
||||||
|
"""Pull the weekly grid out of the homepage, one ``<li>`` per slot."""
|
||||||
|
broadcasts: list[Broadcast] = []
|
||||||
|
for chunk in re.split(r"<li\b", page):
|
||||||
|
if 'class="title"' not in chunk or 'class="time"' not in chunk:
|
||||||
|
continue
|
||||||
|
stamps = re.findall(r'datetime="([^"]+)"', chunk)
|
||||||
|
if len(stamps) < 2:
|
||||||
|
continue
|
||||||
|
start = parse_date(stamps[0])
|
||||||
|
end = parse_date(stamps[1])
|
||||||
|
if start is None:
|
||||||
|
continue
|
||||||
|
title = re.search(r'<p class="title">(.*?)</p>', chunk, re.S)
|
||||||
|
if title is None:
|
||||||
|
continue
|
||||||
|
block = title.group(1)
|
||||||
|
location_match = _SPAN_LOCATION.search(block)
|
||||||
|
location = _text(location_match.group(1)) if location_match else ""
|
||||||
|
name = _text(_SPAN_LOCATION.sub(" ", block))
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
broadcasts.append(
|
||||||
|
Broadcast(name=name, location=location, start=start, end=end)
|
||||||
|
)
|
||||||
|
broadcasts.sort(key=lambda item: item.start or datetime.now(timezone.utc))
|
||||||
|
return broadcasts
|
||||||
|
|
||||||
|
|
||||||
|
def roll_forward(
|
||||||
|
entries: Iterable[Broadcast], now: datetime, limit: int = SCHEDULE_LIMIT
|
||||||
|
) -> list[Broadcast]:
|
||||||
|
"""The homepage only shows one week; repeat it so the pane is never empty."""
|
||||||
|
upcoming: list[Broadcast] = []
|
||||||
|
for entry in entries:
|
||||||
|
start, end = entry.start, entry.end
|
||||||
|
if start is None:
|
||||||
|
continue
|
||||||
|
for _ in range(3):
|
||||||
|
if end is None or end >= now:
|
||||||
|
break
|
||||||
|
start += WEEK
|
||||||
|
end = end + WEEK if end is not None else None
|
||||||
|
if end is not None and end < now:
|
||||||
|
continue
|
||||||
|
upcoming.append(
|
||||||
|
Broadcast(
|
||||||
|
name=entry.name,
|
||||||
|
dj=entry.dj,
|
||||||
|
location=entry.location,
|
||||||
|
start=start,
|
||||||
|
end=end,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
upcoming.sort(key=lambda item: item.start or now)
|
||||||
|
return upcoming[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# caching
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
#: One homepage fetch serves both the station and the schedule worker; the
|
||||||
|
#: lock keeps a cold start from downloading the same grid twice at once.
|
||||||
|
_MEMO_TTL = 120.0
|
||||||
|
_schedule_memo: tuple[float, list[Broadcast]] | None = None
|
||||||
|
_schedule_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _memo_schedule() -> list[Broadcast]:
|
||||||
|
if _schedule_memo is None:
|
||||||
|
return []
|
||||||
|
fetched, entries = _schedule_memo
|
||||||
|
if time.time() - fetched > _MEMO_TTL:
|
||||||
|
return []
|
||||||
|
return list(entries)
|
||||||
|
|
||||||
|
|
||||||
|
def _remember_schedule(entries: list[Broadcast]) -> None:
|
||||||
|
global _schedule_memo
|
||||||
|
_schedule_memo = (time.time(), entries)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_schedule_memo() -> None:
|
||||||
|
"""Forget the in-process schedule (the tests use this)."""
|
||||||
|
global _schedule_memo
|
||||||
|
_schedule_memo = None
|
||||||
|
|
||||||
|
|
||||||
|
def _songs_cache_path(station_id: str) -> Path:
|
||||||
|
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", station_id or DEFAULT_STATION_ID)
|
||||||
|
return config.SONGS_CACHE_DIR / f"{PROVIDER_ID}-{safe}.json"
|
||||||
|
|
||||||
|
|
||||||
|
async def schedule_entries(client: httpx.AsyncClient) -> list[Broadcast]:
|
||||||
|
"""The weekly grid: memo, then the homepage, then the cached copy."""
|
||||||
|
remembered = _memo_schedule()
|
||||||
|
if remembered:
|
||||||
|
return remembered
|
||||||
|
async with _schedule_lock:
|
||||||
|
remembered = _memo_schedule()
|
||||||
|
if remembered:
|
||||||
|
return remembered
|
||||||
|
try:
|
||||||
|
response = await client.get(
|
||||||
|
SCHEDULE_URL, headers={"Accept": "text/html"}
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
entries = parse_schedule(response.text)
|
||||||
|
except httpx.HTTPError:
|
||||||
|
entries = []
|
||||||
|
if not entries:
|
||||||
|
return load_cached_schedule()
|
||||||
|
_remember_schedule(entries)
|
||||||
|
config.write_json(
|
||||||
|
config.schedule_cache_path(PROVIDER_ID),
|
||||||
|
{
|
||||||
|
"fetched": time.time(),
|
||||||
|
"schedule": [
|
||||||
|
{
|
||||||
|
"name": item.name,
|
||||||
|
"location": item.location,
|
||||||
|
"start": item.start.isoformat() if item.start else "",
|
||||||
|
"end": item.end.isoformat() if item.end else "",
|
||||||
|
}
|
||||||
|
for item in entries
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def load_cached_station() -> list[Station]:
|
||||||
|
raw = config.read_json(config.channels_cache_path(PROVIDER_ID))
|
||||||
|
status = raw.get("status") if isinstance(raw, dict) else None
|
||||||
|
if status is None:
|
||||||
|
return []
|
||||||
|
station = station_from_status(status, load_cached_schedule())
|
||||||
|
return [station] if station is not None else []
|
||||||
|
|
||||||
|
|
||||||
|
def load_cached_schedule() -> list[Broadcast]:
|
||||||
|
raw = config.read_json(config.schedule_cache_path(PROVIDER_ID))
|
||||||
|
entries = raw.get("schedule") if isinstance(raw, dict) else None
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
return []
|
||||||
|
return _schedule_from_cache(entries)
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_from_cache(entries: list[Any]) -> list[Broadcast]:
|
||||||
|
broadcasts: list[Broadcast] = []
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
start = parse_date(entry.get("start"))
|
||||||
|
end = parse_date(entry.get("end"))
|
||||||
|
name = str(entry.get("name") or "").strip()
|
||||||
|
if not name or start is None:
|
||||||
|
continue
|
||||||
|
broadcasts.append(
|
||||||
|
Broadcast(
|
||||||
|
name=name,
|
||||||
|
location=str(entry.get("location") or "").strip(),
|
||||||
|
start=start,
|
||||||
|
end=end,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
broadcasts.sort(key=lambda item: item.start or datetime.now(timezone.utc))
|
||||||
|
return broadcasts
|
||||||
|
|
||||||
|
|
||||||
|
def load_cached_songs(station_id: str) -> list[Song]:
|
||||||
|
raw = config.read_json(_songs_cache_path(station_id))
|
||||||
|
entries = raw.get("songs") if isinstance(raw, dict) else None
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
return []
|
||||||
|
songs: list[Song] = []
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
song = Song(
|
||||||
|
title=str(entry.get("title") or "").strip(),
|
||||||
|
artist=str(entry.get("artist") or "").strip(),
|
||||||
|
played_at=parse_date(entry.get("played_at")),
|
||||||
|
)
|
||||||
|
if song.title or song.artist:
|
||||||
|
songs.append(song)
|
||||||
|
return songs
|
||||||
|
|
||||||
|
|
||||||
|
def _songs_to_cache(songs: Iterable[Song]) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"title": song.title,
|
||||||
|
"artist": song.artist,
|
||||||
|
"played_at": song.played_at.isoformat() if song.played_at else "",
|
||||||
|
}
|
||||||
|
for song in songs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# fetching
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_station(client: httpx.AsyncClient) -> Station:
|
||||||
|
try:
|
||||||
|
payload = await fetch_json(client, STATUS_URL)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise RuntimeError(f"could not fetch the Bassdrive status: {exc}") from None
|
||||||
|
|
||||||
|
# The schedule tells the station *where* a live show is coming from, so a
|
||||||
|
# cold start fetches it here; the schedule worker then reuses the memo.
|
||||||
|
schedule = load_cached_schedule() or await schedule_entries(client)
|
||||||
|
station = station_from_status(payload, schedule)
|
||||||
|
if station is None:
|
||||||
|
raise RuntimeError("Bassdrive returned no station data")
|
||||||
|
|
||||||
|
config.write_json(
|
||||||
|
config.channels_cache_path(PROVIDER_ID),
|
||||||
|
{"fetched": time.time(), "status": payload},
|
||||||
|
)
|
||||||
|
return station
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_songs(client: httpx.AsyncClient, station_id: str) -> list[Song]:
|
||||||
|
try:
|
||||||
|
payload = await fetch_json(client, STATUS_URL, attempts=1)
|
||||||
|
except RuntimeError:
|
||||||
|
return load_cached_songs(station_id)
|
||||||
|
|
||||||
|
current = song_from_title(display_title(payload))
|
||||||
|
if current is None:
|
||||||
|
return load_cached_songs(station_id)
|
||||||
|
|
||||||
|
history = load_cached_songs(station_id)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if history and history[0].key() == current.key():
|
||||||
|
# Same show still on air: keep the time we first heard it, so the pane
|
||||||
|
# says how long it has been running, not how long since the last poll.
|
||||||
|
current.played_at = history[0].played_at
|
||||||
|
history = [current, *history[1:]]
|
||||||
|
else:
|
||||||
|
current.played_at = now
|
||||||
|
history = [current] + [song for song in history if song.key() != current.key()]
|
||||||
|
history = history[:HISTORY_LIMIT]
|
||||||
|
|
||||||
|
config.write_json(
|
||||||
|
_songs_cache_path(station_id),
|
||||||
|
{"fetched": time.time(), "songs": _songs_to_cache(history)},
|
||||||
|
)
|
||||||
|
return history
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_schedule(client: httpx.AsyncClient) -> list[Broadcast]:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
live: Broadcast | None = None
|
||||||
|
try:
|
||||||
|
status = await fetch_json(client, STATUS_URL, attempts=1)
|
||||||
|
except RuntimeError:
|
||||||
|
status = None
|
||||||
|
|
||||||
|
entries = await schedule_entries(client)
|
||||||
|
upcoming = roll_forward(entries, now)
|
||||||
|
if status is not None:
|
||||||
|
live = live_broadcast(status, entries, now)
|
||||||
|
if live is not None:
|
||||||
|
key = live.key()
|
||||||
|
upcoming = [item for item in upcoming if item.key() != key]
|
||||||
|
return [live, *upcoming]
|
||||||
|
return upcoming
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Bundled station, so a cold offline start still makes sense.
|
||||||
|
|
||||||
|
Bassdrive is one station with three mounts and a handful of mirror hosts; the
|
||||||
|
streams below are the ones its own ``.m3u`` files list. The live status-json
|
||||||
|
always wins when it is reachable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# (url, format, quality, bitrate)
|
||||||
|
_MOUNTS: tuple[tuple[str, str, str, str], ...] = (
|
||||||
|
("https://ice.bassdrive.net:443/stream", "mp3", "highest", "128"),
|
||||||
|
("https://ice.bassdrive.net:443/stream56", "aacp", "high", "56"),
|
||||||
|
("https://ice.bassdrive.net:443/stream32", "aacp", "low", "32"),
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Alternative servers from bassdrive.m3u, tried when the primary is down.
|
||||||
|
_MIRRORS: tuple[str, ...] = (
|
||||||
|
"http://chi.bassdrive.co:80",
|
||||||
|
"http://bassdrive.radioca.st:8702",
|
||||||
|
"http://stream.bassdrive.uk:8200",
|
||||||
|
"http://au.bassdrive.co:8000",
|
||||||
|
)
|
||||||
|
|
||||||
|
_SERVER_TYPE = {"mp3": "audio/mpeg", "aacp": "audio/aacp"}
|
||||||
|
|
||||||
|
|
||||||
|
def fallback_status() -> dict[str, Any]:
|
||||||
|
"""A ``status-json.xsl``-shaped payload, so the normal parser can eat it."""
|
||||||
|
return {
|
||||||
|
"icestats": {
|
||||||
|
"source": [
|
||||||
|
{
|
||||||
|
"listenurl": url,
|
||||||
|
"bitrate": int(bitrate),
|
||||||
|
"server_type": _SERVER_TYPE.get(fmt, "audio/mpeg"),
|
||||||
|
"listeners": 0,
|
||||||
|
"display-title": "",
|
||||||
|
}
|
||||||
|
for url, fmt, _quality, bitrate in _MOUNTS
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fallback_stations() -> list[Any]:
|
||||||
|
from .api import station_from_status
|
||||||
|
|
||||||
|
station = station_from_status(fallback_status())
|
||||||
|
return [station] if station is not None else []
|
||||||
|
|
||||||
|
|
||||||
|
def known_stream_urls() -> list[str]:
|
||||||
|
return [url for url, _format, _quality, _bitrate in _MOUNTS] + list(_MIRRORS)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Bassdrive's header hero: its wordmark in the station's two blues.
|
||||||
|
|
||||||
|
The logo paints "BASS" in the pale blue and "DRIVE" in the deeper one; those
|
||||||
|
map onto the stock palette's bright cyan and cyan, so the wordmark still reads
|
||||||
|
as Bassdrive without hard-coded RGB.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..wordmark import render
|
||||||
|
|
||||||
|
HERO: tuple[str, ...] = render(
|
||||||
|
"BASSDRIVE", ["bright_cyan"] * 4 + ["cyan"] * 5
|
||||||
|
)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Independent stations: one relay each, with the metadata the relay offers.
|
||||||
|
|
||||||
|
Each module here is a thin subclass of
|
||||||
|
:class:`beefy_player.providers.community.base.CommunityProvider`, which knows
|
||||||
|
how to read an Icecast status document or a radio.co status, keep a rolling
|
||||||
|
history and (for Dublab) follow a published schedule.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .anonradio import AnonradioProvider
|
||||||
|
from .cashmere import CashmereProvider
|
||||||
|
from .datafruits import DatafruitsProvider
|
||||||
|
from .dublab import DublabProvider
|
||||||
|
from .lot import LotProvider
|
||||||
|
from .lyl import LylProvider
|
||||||
|
from .refuge import RefugeProvider
|
||||||
|
from .rinse import RinseProvider
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AnonradioProvider",
|
||||||
|
"CashmereProvider",
|
||||||
|
"DatafruitsProvider",
|
||||||
|
"DublabProvider",
|
||||||
|
"LotProvider",
|
||||||
|
"LylProvider",
|
||||||
|
"RefugeProvider",
|
||||||
|
"RinseProvider",
|
||||||
|
]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""aNONradio: SDF's community station, on Icecast since the dial was young."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .base import CommunityProvider
|
||||||
|
|
||||||
|
|
||||||
|
class AnonradioProvider(CommunityProvider):
|
||||||
|
id = "anonradio"
|
||||||
|
name = "aNONradio"
|
||||||
|
homepage = "anonradio.net"
|
||||||
|
brand = "aNONradio"
|
||||||
|
tagline = "SDF community radio"
|
||||||
|
station_source = "anonradio.net icecast"
|
||||||
|
history_source = "icecast title (rolling)"
|
||||||
|
|
||||||
|
station_title = "aNONradio"
|
||||||
|
station_description = (
|
||||||
|
"community station of the SDF public-access UNIX system"
|
||||||
|
)
|
||||||
|
station_genre = "eclectic|talk"
|
||||||
|
streams = (("https://anonradio.net:8443/anonradio", "mp3", "high", "192"),)
|
||||||
|
status_url = "https://anonradio.net:8443/status-json.xsl"
|
||||||
|
|
||||||
|
wordmark = "ANONRADIO"
|
||||||
|
hero_lines = ("SDF COMMUNITY RADIO", "PUBLIC ACCESS UNIX")
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
"""Community stations: one relay each, sometimes with a status document.
|
||||||
|
|
||||||
|
Most independent stations are a single stream on Icecast, radio.co or Airtime,
|
||||||
|
plus — if you are lucky — a status document that names what is on. This module
|
||||||
|
turns that into a provider: the station, its relays, and a rolling history of
|
||||||
|
whatever the relay has announced while you were listening.
|
||||||
|
|
||||||
|
A subclass only carries data. Stations with a schedule or a multi-station
|
||||||
|
catalogue (Dublab, Rinse, Radio Paradise) override a hook or two.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ... import config
|
||||||
|
from ...models import Broadcast, Song, Station, parse_date, parse_last_playing, to_int
|
||||||
|
from ..base import RadioProvider, fetch_json
|
||||||
|
from ..wordmark import render
|
||||||
|
|
||||||
|
#: ``public.radio.co`` status document: keyless, no station id in the body.
|
||||||
|
RADIO_CO_STATUS = "https://public.radio.co/stations/{station}/status"
|
||||||
|
|
||||||
|
#: How many shows/tracks LAST PLAYED remembers.
|
||||||
|
HISTORY_LIMIT = 20
|
||||||
|
|
||||||
|
|
||||||
|
class CommunityProvider(RadioProvider):
|
||||||
|
"""A catalogue that broadcasts one stream (or a fixed handful)."""
|
||||||
|
|
||||||
|
# -- catalogue data (set by each station module) ---------------------- #
|
||||||
|
|
||||||
|
station_title = ""
|
||||||
|
station_description = ""
|
||||||
|
station_genre = "eclectic"
|
||||||
|
station_image = ""
|
||||||
|
|
||||||
|
#: ``(url, format, quality, bitrate)`` — the relays, best first.
|
||||||
|
streams: tuple[tuple[str, str, str, str], ...] = ()
|
||||||
|
#: Keyless Icecast ``status-json.xsl``, when the relay publishes one.
|
||||||
|
status_url = ""
|
||||||
|
#: ``public.radio.co`` station id, when the relay is radio.co.
|
||||||
|
radio_co_id = ""
|
||||||
|
#: Wordmark the header spells; empty leaves the provider without a hero.
|
||||||
|
wordmark = ""
|
||||||
|
|
||||||
|
# Independent stations are not Bandcamp catalogues.
|
||||||
|
bandcamp = False
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
if self.wordmark:
|
||||||
|
self.hero = render(self.wordmark)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# station
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _playlists(self) -> list[dict[str, str]]:
|
||||||
|
return [
|
||||||
|
{"url": url, "format": fmt, "quality": quality, "bitrate": bitrate}
|
||||||
|
for url, fmt, quality, bitrate in self.streams
|
||||||
|
]
|
||||||
|
|
||||||
|
def station(self) -> Station:
|
||||||
|
return Station(
|
||||||
|
id=self.id,
|
||||||
|
title=self.station_title or self.name,
|
||||||
|
description=self.station_description or self.tagline,
|
||||||
|
genre=self.station_genre,
|
||||||
|
image=self.station_image,
|
||||||
|
playlists=self._playlists(),
|
||||||
|
provider=self.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def stations(self) -> list[Station]:
|
||||||
|
return [self.station()]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# what the relay says is on
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_status(self) -> bool:
|
||||||
|
return bool(self.status_url or self.radio_co_id)
|
||||||
|
|
||||||
|
async def _status(self, client: httpx.AsyncClient, attempts: int = 2) -> Any:
|
||||||
|
url = (
|
||||||
|
RADIO_CO_STATUS.format(station=self.radio_co_id)
|
||||||
|
if self.radio_co_id
|
||||||
|
else self.status_url
|
||||||
|
)
|
||||||
|
return await fetch_json(client, url, attempts=attempts)
|
||||||
|
|
||||||
|
def _listeners(self, payload: Any) -> int:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return 0
|
||||||
|
if self.radio_co_id:
|
||||||
|
return to_int(payload.get("listeners"))
|
||||||
|
stats = payload.get("icestats")
|
||||||
|
source = stats.get("source") if isinstance(stats, dict) else None
|
||||||
|
sources = (
|
||||||
|
source
|
||||||
|
if isinstance(source, list)
|
||||||
|
else [source]
|
||||||
|
if isinstance(source, dict)
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
return sum(
|
||||||
|
to_int(item.get("listeners")) for item in sources if isinstance(item, dict)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _icecast_title(self, payload: Any) -> str:
|
||||||
|
stats = payload.get("icestats") if isinstance(payload, dict) else None
|
||||||
|
source = stats.get("source") if isinstance(stats, dict) else None
|
||||||
|
sources = (
|
||||||
|
source
|
||||||
|
if isinstance(source, list)
|
||||||
|
else [source]
|
||||||
|
if isinstance(source, dict)
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
for item in sources:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
for key in ("display-title", "title", "yp_currently_playing"):
|
||||||
|
value = item.get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _now(self, payload: Any) -> Song | None:
|
||||||
|
"""The relay's current track/show, as a :class:`Song`."""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
if self.radio_co_id:
|
||||||
|
track = payload.get("current_track")
|
||||||
|
if not isinstance(track, dict):
|
||||||
|
return None
|
||||||
|
title = str(track.get("title") or "").strip()
|
||||||
|
if not title:
|
||||||
|
return None
|
||||||
|
song = parse_last_playing(title) or Song(title=title)
|
||||||
|
song.art_url = str(track.get("artwork_url") or "").strip()
|
||||||
|
song.played_at = parse_date(track.get("start_time"))
|
||||||
|
return song
|
||||||
|
|
||||||
|
title = self._icecast_title(payload)
|
||||||
|
if not title:
|
||||||
|
return None
|
||||||
|
return parse_last_playing(title)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# the schedule, when the catalogue has one (Dublab)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _scheduled_now(self) -> Broadcast | None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for entry in self.load_cached_schedule():
|
||||||
|
if entry.start is None or entry.end is None:
|
||||||
|
continue
|
||||||
|
if entry.start <= now < entry.end:
|
||||||
|
return entry
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _mark_live(self, entries: list[Broadcast]) -> list[Broadcast]:
|
||||||
|
"""Flag the slot that is running now, so the pane can say ON AIR."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
marked: list[Broadcast] = []
|
||||||
|
for entry in entries:
|
||||||
|
if (
|
||||||
|
entry.start is not None
|
||||||
|
and entry.end is not None
|
||||||
|
and entry.start <= now < entry.end
|
||||||
|
):
|
||||||
|
marked.append(
|
||||||
|
Broadcast(
|
||||||
|
name=entry.name,
|
||||||
|
dj=entry.dj,
|
||||||
|
location=entry.location,
|
||||||
|
start=entry.start,
|
||||||
|
end=entry.end,
|
||||||
|
live=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
marked.append(entry)
|
||||||
|
return marked
|
||||||
|
|
||||||
|
def _scheduled_song(self) -> Song | None:
|
||||||
|
entry = self._scheduled_now()
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
return Song(artist=self.name, title=entry.name)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# caching
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _station_cache(self) -> Path:
|
||||||
|
return config.channels_cache_path(self.id)
|
||||||
|
|
||||||
|
def _songs_cache(self, station_id: str) -> Path:
|
||||||
|
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", station_id or self.id)
|
||||||
|
return config.SONGS_CACHE_DIR / f"{self.id}-{safe}.json"
|
||||||
|
|
||||||
|
def _songs_to_cache(self, songs: list[Song]) -> list[dict[str, str]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"title": song.title,
|
||||||
|
"artist": song.artist,
|
||||||
|
"art": song.art_url,
|
||||||
|
"played_at": song.played_at.isoformat() if song.played_at else "",
|
||||||
|
}
|
||||||
|
for song in songs
|
||||||
|
]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# RadioProvider
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
|
||||||
|
stations = self.stations()
|
||||||
|
payload: Any = None
|
||||||
|
if self.has_status:
|
||||||
|
try:
|
||||||
|
payload = await self._status(client)
|
||||||
|
except RuntimeError:
|
||||||
|
payload = None
|
||||||
|
if payload is None:
|
||||||
|
payload = self._cached_status()
|
||||||
|
else:
|
||||||
|
config.write_json(
|
||||||
|
self._station_cache(), {"fetched": time.time(), "status": payload}
|
||||||
|
)
|
||||||
|
|
||||||
|
song = self._now(payload) if payload is not None else None
|
||||||
|
if song is None:
|
||||||
|
song = self._scheduled_song()
|
||||||
|
listeners = self._listeners(payload) if payload is not None else 0
|
||||||
|
entry = self._scheduled_now()
|
||||||
|
|
||||||
|
for station in stations:
|
||||||
|
if song is not None:
|
||||||
|
station.now_playing = song
|
||||||
|
station.last_playing = (
|
||||||
|
f"{song.artist} - {song.title}" if song.artist else song.title
|
||||||
|
)
|
||||||
|
if listeners:
|
||||||
|
station.listeners = listeners
|
||||||
|
if entry is not None:
|
||||||
|
station.presenter = (
|
||||||
|
f"live from {entry.location}" if entry.location else "live now"
|
||||||
|
)
|
||||||
|
return stations
|
||||||
|
|
||||||
|
def _cached_status(self) -> Any:
|
||||||
|
raw = config.read_json(self._station_cache())
|
||||||
|
return raw.get("status") if isinstance(raw, dict) else None
|
||||||
|
|
||||||
|
def load_cached_stations(self) -> list[Station]:
|
||||||
|
stations = self.stations()
|
||||||
|
payload = self._cached_status()
|
||||||
|
song = self._now(payload) if payload is not None else None
|
||||||
|
if song is None:
|
||||||
|
song = self._scheduled_song()
|
||||||
|
listeners = self._listeners(payload) if payload is not None else 0
|
||||||
|
for station in stations:
|
||||||
|
if song is not None:
|
||||||
|
station.now_playing = song
|
||||||
|
if listeners:
|
||||||
|
station.listeners = listeners
|
||||||
|
return stations
|
||||||
|
|
||||||
|
def fallback_stations(self) -> list[Station]:
|
||||||
|
return self.stations()
|
||||||
|
|
||||||
|
async def fetch_history(
|
||||||
|
self, client: httpx.AsyncClient, station_id: str
|
||||||
|
) -> list[Song]:
|
||||||
|
current: Song | None = None
|
||||||
|
if self.has_status:
|
||||||
|
try:
|
||||||
|
payload = await self._status(client, attempts=1)
|
||||||
|
except RuntimeError:
|
||||||
|
payload = None
|
||||||
|
if payload is not None:
|
||||||
|
current = self._now(payload)
|
||||||
|
if current is None:
|
||||||
|
current = self._scheduled_song()
|
||||||
|
if current is None:
|
||||||
|
return self.load_cached_history(station_id)
|
||||||
|
|
||||||
|
history = self.load_cached_history(station_id)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if history and history[0].key() == current.key():
|
||||||
|
# Same show or track: keep the time we first heard it.
|
||||||
|
current.played_at = history[0].played_at
|
||||||
|
history = [current, *history[1:]]
|
||||||
|
else:
|
||||||
|
current.played_at = current.played_at or now
|
||||||
|
history = [current, *[s for s in history if s.key() != current.key()]]
|
||||||
|
history = history[:HISTORY_LIMIT]
|
||||||
|
|
||||||
|
config.write_json(
|
||||||
|
self._songs_cache(station_id),
|
||||||
|
{"fetched": time.time(), "songs": self._songs_to_cache(history)},
|
||||||
|
)
|
||||||
|
return history
|
||||||
|
|
||||||
|
def load_cached_history(self, station_id: str) -> list[Song]:
|
||||||
|
raw = config.read_json(self._songs_cache(station_id))
|
||||||
|
entries = raw.get("songs") if isinstance(raw, dict) else None
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
return []
|
||||||
|
songs: list[Song] = []
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
song = Song(
|
||||||
|
title=str(entry.get("title") or "").strip(),
|
||||||
|
artist=str(entry.get("artist") or "").strip(),
|
||||||
|
art_url=str(entry.get("art") or "").strip(),
|
||||||
|
played_at=parse_date(entry.get("played_at")),
|
||||||
|
)
|
||||||
|
if song.title or song.artist:
|
||||||
|
songs.append(song)
|
||||||
|
return songs
|
||||||
|
|
||||||
|
def fallback_urls(self, station: Station) -> list[str]:
|
||||||
|
return [url for url, _format, _quality, _bitrate in self.streams]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Cashmere Radio: Berlin's experimental station, on an Airtime relay."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .base import CommunityProvider
|
||||||
|
|
||||||
|
|
||||||
|
class CashmereProvider(CommunityProvider):
|
||||||
|
id = "cashmere"
|
||||||
|
name = "Cashmere Radio"
|
||||||
|
homepage = "cashmereradio.com"
|
||||||
|
brand = "CASHMERE"
|
||||||
|
tagline = "experimental radio · berlin"
|
||||||
|
station_source = "cashmereradio.com"
|
||||||
|
history_source = ""
|
||||||
|
|
||||||
|
station_title = "Cashmere Radio"
|
||||||
|
station_description = "experimental radio from Berlin"
|
||||||
|
station_genre = "experimental|eclectic"
|
||||||
|
streams = (
|
||||||
|
("https://cashmereradio.out.airtime.pro/cashmereradio_a", "ogg", "high", "192"),
|
||||||
|
("https://cashmereradio.out.airtime.pro/cashmereradio_b", "mp3", "high", "192"),
|
||||||
|
)
|
||||||
|
|
||||||
|
wordmark = "CASHMERE"
|
||||||
|
hero_lines = ("EXPERIMENTAL RADIO", "BERLIN")
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Datafruits: the netlabel's internet radio, relayed by Streampusher."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .base import CommunityProvider
|
||||||
|
|
||||||
|
|
||||||
|
class DatafruitsProvider(CommunityProvider):
|
||||||
|
id = "datafruits"
|
||||||
|
name = "Datafruits"
|
||||||
|
homepage = "datafruits.fm"
|
||||||
|
brand = "DATAFRUITS"
|
||||||
|
tagline = "netlabel radio · experimental"
|
||||||
|
station_source = "streampusher relay + icecast status"
|
||||||
|
history_source = "icecast title (rolling)"
|
||||||
|
|
||||||
|
station_title = "Datafruits"
|
||||||
|
station_description = "internet radio from the Datafruits netlabel"
|
||||||
|
station_genre = "electronic|experimental"
|
||||||
|
streams = (("https://streampusher-relay.club/datafruits.mp3", "mp3", "high", "128"),)
|
||||||
|
status_url = "https://streampusher-relay.club/status-json.xsl"
|
||||||
|
|
||||||
|
wordmark = "DATAFRUITS"
|
||||||
|
hero_lines = ("NETLABEL RADIO", "EXPERIMENTAL ELECTRONIC")
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""dublab: Los Angeles community radio, with a public weekly schedule."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ... import config
|
||||||
|
from ...models import Broadcast, parse_date
|
||||||
|
from ..base import fetch_json
|
||||||
|
from .base import CommunityProvider
|
||||||
|
|
||||||
|
SCHEDULE_URL = "https://dublab.com/.netlify/functions/schedule"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_schedule(payload: Any) -> list[Broadcast]:
|
||||||
|
"""The site's own calendar: ``[{startTime, endTime, summary, ...}]``."""
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
return []
|
||||||
|
broadcasts: list[Broadcast] = []
|
||||||
|
for entry in payload:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
name = str(entry.get("summary") or entry.get("title") or "").strip()
|
||||||
|
start = parse_date(entry.get("startTime") or entry.get("start"))
|
||||||
|
end = parse_date(entry.get("endTime") or entry.get("end"))
|
||||||
|
if not name or start is None:
|
||||||
|
continue
|
||||||
|
broadcasts.append(Broadcast(name=name, start=start, end=end))
|
||||||
|
broadcasts.sort(key=lambda item: item.start or datetime.now(timezone.utc))
|
||||||
|
return broadcasts
|
||||||
|
|
||||||
|
|
||||||
|
class DublabProvider(CommunityProvider):
|
||||||
|
id = "dublab"
|
||||||
|
name = "dublab"
|
||||||
|
homepage = "dublab.com"
|
||||||
|
brand = "DUBLAB"
|
||||||
|
tagline = "community radio · los angeles · since 1999"
|
||||||
|
station_source = "dublab.com schedule"
|
||||||
|
history_source = "dublab.com schedule (rolling)"
|
||||||
|
|
||||||
|
station_title = "dublab"
|
||||||
|
station_description = "non-profit community radio from Los Angeles, since 1999"
|
||||||
|
station_genre = "eclectic|experimental"
|
||||||
|
streams = (("https://dublab.out.airtime.pro/dublab_a", "mp3", "high", "192"),)
|
||||||
|
|
||||||
|
# The schedule is the only metadata dublab publishes, so it drives both the
|
||||||
|
# left pane and "what is on".
|
||||||
|
has_schedule = True
|
||||||
|
|
||||||
|
wordmark = "DUBLAB"
|
||||||
|
hero_lines = ("LOS ANGELES", "SINCE 1999")
|
||||||
|
|
||||||
|
async def fetch_schedule(self, client: httpx.AsyncClient) -> list[Broadcast]:
|
||||||
|
try:
|
||||||
|
payload = await fetch_json(client, SCHEDULE_URL, attempts=1)
|
||||||
|
except RuntimeError:
|
||||||
|
return self.load_cached_schedule()
|
||||||
|
entries = self._mark_live(parse_schedule(payload))
|
||||||
|
if not entries:
|
||||||
|
return self.load_cached_schedule()
|
||||||
|
config.write_json(
|
||||||
|
config.schedule_cache_path(self.id),
|
||||||
|
{
|
||||||
|
"fetched": time.time(),
|
||||||
|
"schedule": [
|
||||||
|
{
|
||||||
|
"name": item.name,
|
||||||
|
"start": item.start.isoformat() if item.start else "",
|
||||||
|
"end": item.end.isoformat() if item.end else "",
|
||||||
|
}
|
||||||
|
for item in entries
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
async def fetch_history(self, client: httpx.AsyncClient, station_id: str):
|
||||||
|
# The schedule is the only "what is on" dublab publishes, so make sure
|
||||||
|
# we have one before the base class looks for the running show.
|
||||||
|
if not self.load_cached_schedule():
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await self.fetch_schedule(client)
|
||||||
|
return await super().fetch_history(client, station_id)
|
||||||
|
|
||||||
|
def load_cached_schedule(self) -> list[Broadcast]:
|
||||||
|
raw = config.read_json(config.schedule_cache_path(self.id))
|
||||||
|
entries = raw.get("schedule") if isinstance(raw, dict) else None
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
return []
|
||||||
|
broadcasts: list[Broadcast] = []
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
start = parse_date(entry.get("start"))
|
||||||
|
name = str(entry.get("name") or "").strip()
|
||||||
|
if not name or start is None:
|
||||||
|
continue
|
||||||
|
broadcasts.append(
|
||||||
|
Broadcast(name=name, start=start, end=parse_date(entry.get("end")))
|
||||||
|
)
|
||||||
|
broadcasts.sort(key=lambda item: item.start or datetime.now(timezone.utc))
|
||||||
|
return broadcasts
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""The Lot Radio: live from a shipping container in Brooklyn (HLS)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .base import CommunityProvider
|
||||||
|
|
||||||
|
|
||||||
|
class LotProvider(CommunityProvider):
|
||||||
|
id = "lot"
|
||||||
|
name = "The Lot Radio"
|
||||||
|
homepage = "thelotradio.com"
|
||||||
|
brand = "THELOT"
|
||||||
|
tagline = "live from the lot · brooklyn"
|
||||||
|
station_source = "thelotradio.com (HLS)"
|
||||||
|
history_source = ""
|
||||||
|
|
||||||
|
station_title = "The Lot Radio"
|
||||||
|
station_description = "live radio from a shipping container in Brooklyn"
|
||||||
|
station_genre = "eclectic|house"
|
||||||
|
# The station streams HLS; mpv plays the playlist directly.
|
||||||
|
streams = (
|
||||||
|
(
|
||||||
|
"https://livepeercdn.studio/hls/85c28sa2o8wppm58/index.m3u8",
|
||||||
|
"aac",
|
||||||
|
"high",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
wordmark = "THELOT"
|
||||||
|
hero_lines = ("BROOKLYN", "LIVE FROM THE LOT")
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""LYL Radio: independent webradio from Lyon and Paris."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .base import CommunityProvider
|
||||||
|
|
||||||
|
|
||||||
|
class LylProvider(CommunityProvider):
|
||||||
|
id = "lyl"
|
||||||
|
name = "LYL Radio"
|
||||||
|
homepage = "lyl.live"
|
||||||
|
brand = "LYL"
|
||||||
|
tagline = "we're the rest · lyon · paris"
|
||||||
|
station_source = "icecast.lyl.live"
|
||||||
|
history_source = ""
|
||||||
|
|
||||||
|
station_title = "LYL Radio"
|
||||||
|
station_description = "independent webradio from Lyon and Paris"
|
||||||
|
station_genre = "eclectic|experimental"
|
||||||
|
streams = (("https://icecast.lyl.live/live", "mp3", "high", "192"),)
|
||||||
|
|
||||||
|
wordmark = "LYL"
|
||||||
|
hero_lines = ("WE'RE THE REST", "LYON · PARIS")
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Refuge Worldwide: Berlin community radio on a radio.co relay."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .base import CommunityProvider
|
||||||
|
|
||||||
|
|
||||||
|
class RefugeProvider(CommunityProvider):
|
||||||
|
id = "refuge"
|
||||||
|
name = "Refuge Worldwide"
|
||||||
|
homepage = "refugeworldwide.com"
|
||||||
|
brand = "REFUGE"
|
||||||
|
tagline = "community radio · berlin"
|
||||||
|
station_source = "radio.co status"
|
||||||
|
history_source = "radio.co now playing (rolling)"
|
||||||
|
|
||||||
|
station_title = "Refuge Worldwide"
|
||||||
|
station_description = "community radio from Berlin"
|
||||||
|
station_genre = "eclectic|electronic"
|
||||||
|
streams = (("https://streaming.radio.co/s3699c5e49/listen", "mp3", "high", "192"),)
|
||||||
|
radio_co_id = "s3699c5e49"
|
||||||
|
|
||||||
|
wordmark = "REFUGE"
|
||||||
|
hero_lines = ("COMMUNITY RADIO", "BERLIN")
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Rinse FM: London's station family, a proxy mount each."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ...models import Station
|
||||||
|
from .base import CommunityProvider
|
||||||
|
|
||||||
|
#: (station id, title, genre, mount, description)
|
||||||
|
_STATIONS: tuple[tuple[str, str, str, str, str], ...] = (
|
||||||
|
(
|
||||||
|
"rinse_uk",
|
||||||
|
"Rinse UK",
|
||||||
|
"electronic|house",
|
||||||
|
"rinse_uk",
|
||||||
|
"London's underground dance station, on air since 1994",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"kool",
|
||||||
|
"Kool London",
|
||||||
|
"jungle|drum & bass",
|
||||||
|
"kool",
|
||||||
|
"Kool London: jungle, drum & bass and roots",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"swu",
|
||||||
|
"SWU",
|
||||||
|
"soul|eclectic",
|
||||||
|
"swu",
|
||||||
|
"SWU: soul, funk and everything south west",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RinseProvider(CommunityProvider):
|
||||||
|
id = "rinse"
|
||||||
|
name = "Rinse FM"
|
||||||
|
homepage = "rinse.fm"
|
||||||
|
brand = "RINSE"
|
||||||
|
tagline = "london · since 1994"
|
||||||
|
station_source = "rinse.fm streams"
|
||||||
|
history_source = ""
|
||||||
|
|
||||||
|
station_genre = "electronic"
|
||||||
|
|
||||||
|
wordmark = "RINSE"
|
||||||
|
hero_lines = ("LONDON", "SINCE 1994")
|
||||||
|
|
||||||
|
def stations(self) -> list[Station]:
|
||||||
|
return [
|
||||||
|
Station(
|
||||||
|
id=station_id,
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
genre=genre,
|
||||||
|
playlists=[
|
||||||
|
{
|
||||||
|
"url": f"https://admin.stream.rinse.fm/proxy/{mount}/stream",
|
||||||
|
"format": "aacp",
|
||||||
|
"quality": "high",
|
||||||
|
"bitrate": "128",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
provider=self.id,
|
||||||
|
)
|
||||||
|
for station_id, title, genre, mount, description in _STATIONS
|
||||||
|
]
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Radio Browser: a directory of the community's stations rather than a station.
|
||||||
|
|
||||||
|
The most-clicked working relays are loaded a page at a time, and the rest of
|
||||||
|
the 60,000-station directory is searched on the server as you type. There is no
|
||||||
|
now-playing API, so this catalogue offers breadth, not history: pick a station
|
||||||
|
and the stream speaks for itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ... import config
|
||||||
|
from ...models import Song, Station
|
||||||
|
from ..base import RadioProvider, StationPage
|
||||||
|
from . import api, fallback
|
||||||
|
from . import hero as hero_art
|
||||||
|
|
||||||
|
__all__ = ["RadioBrowserProvider"]
|
||||||
|
|
||||||
|
|
||||||
|
class RadioBrowserProvider(RadioProvider):
|
||||||
|
id = "radiobrowser"
|
||||||
|
name = "Radio Browser"
|
||||||
|
homepage = "radio-browser.info"
|
||||||
|
brand = "RADIO BROWSER"
|
||||||
|
tagline = "community directory · live search"
|
||||||
|
station_source = "radio-browser.info (paged + search)"
|
||||||
|
history_source = ""
|
||||||
|
|
||||||
|
hero = hero_art.HERO
|
||||||
|
hero_lines = ("RADIO BROWSER", "COMMUNITY DIRECTORY", "KEYLESS · OPEN DATA")
|
||||||
|
|
||||||
|
# A directory has no show schedule and no Bandcamp worth scraping.
|
||||||
|
bandcamp = False
|
||||||
|
|
||||||
|
# The full directory is much larger than the list fetched at startup; the
|
||||||
|
# app asks the API when the filter changes.
|
||||||
|
server_search = True
|
||||||
|
|
||||||
|
# The directory is loaded a page at a time, in click-count order.
|
||||||
|
paginated = True
|
||||||
|
preserve_order = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_agent(self) -> str:
|
||||||
|
return f"{config.USER_AGENT}; +https://www.radio-browser.info"
|
||||||
|
|
||||||
|
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
|
||||||
|
return await api.fetch_stations(client)
|
||||||
|
|
||||||
|
async def fetch_station_page(
|
||||||
|
self, client: httpx.AsyncClient, offset: int = 0
|
||||||
|
) -> StationPage:
|
||||||
|
return await api.fetch_station_page(client, offset)
|
||||||
|
|
||||||
|
async def fetch_stations_by_ids(
|
||||||
|
self, client: httpx.AsyncClient, station_ids: list[str]
|
||||||
|
) -> list[Station]:
|
||||||
|
return await api.fetch_stations_by_ids(client, station_ids)
|
||||||
|
|
||||||
|
async def search_stations(
|
||||||
|
self, client: httpx.AsyncClient, query: str
|
||||||
|
) -> list[Station]:
|
||||||
|
return await api.search_stations(client, query)
|
||||||
|
|
||||||
|
def load_cached_stations(self) -> list[Station]:
|
||||||
|
return api.load_cached_stations()
|
||||||
|
|
||||||
|
def fallback_stations(self) -> list[Station]:
|
||||||
|
return fallback.fallback_stations()
|
||||||
|
|
||||||
|
async def fetch_history(
|
||||||
|
self, client: httpx.AsyncClient, station_id: str
|
||||||
|
) -> list[Song]:
|
||||||
|
# Radio Browser publishes no now-playing data at all.
|
||||||
|
return []
|
||||||
|
|
||||||
|
def load_cached_history(self, station_id: str) -> list[Song]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def fallback_urls(self, station: Station) -> list[str]:
|
||||||
|
return [entry["url"] for entry in station.sorted_playlists()]
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"""Radio Browser: the community's own station directory.
|
||||||
|
|
||||||
|
``https://de1.api.radio-browser.info/json/stations/search`` is keyless, CORS-open
|
||||||
|
and public-domain data. It knows nothing about what a station is playing —
|
||||||
|
there is no per-station history — so this catalogue is a *directory*: the most
|
||||||
|
clicked working stations, a page at a time. Whatever the relay puts in its
|
||||||
|
stream is what you hear; the app has no metadata to show.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ... import config
|
||||||
|
from ...models import Station, to_int
|
||||||
|
from ..base import StationPage, fetch_json
|
||||||
|
|
||||||
|
API_URL = "https://de1.api.radio-browser.info/json/stations/search"
|
||||||
|
BY_UUID_URL = "https://de1.api.radio-browser.info/json/stations/byuuid"
|
||||||
|
|
||||||
|
#: Stations per directory page. The app loads the first page at startup and
|
||||||
|
#: the next ones as the station list nears its end.
|
||||||
|
PAGE_SIZE = 100
|
||||||
|
|
||||||
|
#: Results per field for a directory search. The four fields are merged.
|
||||||
|
SEARCH_LIMIT = 100
|
||||||
|
|
||||||
|
#: The search endpoint has no full-text parameter, so a free-text query is
|
||||||
|
#: sent once per field and the answers are merged.
|
||||||
|
_SEARCH_FIELDS = ("name", "tag", "country", "language")
|
||||||
|
|
||||||
|
#: Radio Browser ids are UUIDs; anything else is another catalogue's favourite.
|
||||||
|
_UUID_PATTERN = re.compile(
|
||||||
|
r"^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$"
|
||||||
|
)
|
||||||
|
|
||||||
|
#: How many UUIDs to ask for in one ``byuuid`` request.
|
||||||
|
LOOKUP_BATCH = 50
|
||||||
|
|
||||||
|
_PARAMS = {
|
||||||
|
"hidebroken": "true",
|
||||||
|
"order": "clickcount",
|
||||||
|
"reverse": "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
_CODECS = {"mp3": "mp3", "aac": "aac", "aac+": "aacp", "ogg": "ogg", "opus": "opus"}
|
||||||
|
|
||||||
|
|
||||||
|
def _quality(bitrate: int) -> str:
|
||||||
|
if bitrate >= 192:
|
||||||
|
return "highest"
|
||||||
|
if bitrate >= 96:
|
||||||
|
return "high"
|
||||||
|
return "low" if bitrate else ""
|
||||||
|
|
||||||
|
|
||||||
|
def station_from_entry(entry: dict[str, Any]) -> Station | None:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
return None
|
||||||
|
name = str(entry.get("name") or "").strip()
|
||||||
|
url = str(entry.get("url_resolved") or entry.get("url") or "").strip()
|
||||||
|
station_id = str(entry.get("stationuuid") or "").strip()
|
||||||
|
if not name or not station_id or not url.startswith(("http://", "https://")):
|
||||||
|
return None
|
||||||
|
|
||||||
|
bitrate = to_int(entry.get("bitrate"))
|
||||||
|
codec = str(entry.get("codec") or "").strip().lower()
|
||||||
|
tags = [
|
||||||
|
tag.strip()
|
||||||
|
for tag in str(entry.get("tags") or "").split(",")
|
||||||
|
if tag.strip()
|
||||||
|
][:3]
|
||||||
|
country = str(entry.get("country") or "").strip()
|
||||||
|
language = str(entry.get("language") or "").strip()
|
||||||
|
description = " · ".join(part for part in (country, language) if part)
|
||||||
|
|
||||||
|
favicon = str(entry.get("favicon") or "").strip()
|
||||||
|
|
||||||
|
return Station(
|
||||||
|
id=station_id,
|
||||||
|
title=name,
|
||||||
|
description=description or "internet radio",
|
||||||
|
genre="|".join(tags),
|
||||||
|
image=favicon if favicon.startswith(("http://", "https://")) else "",
|
||||||
|
playlists=[
|
||||||
|
{
|
||||||
|
"url": url,
|
||||||
|
"format": _CODECS.get(codec, codec),
|
||||||
|
"quality": _quality(bitrate),
|
||||||
|
"bitrate": str(bitrate) if bitrate else "",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
provider="radiobrowser",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_stations(payload: Any, *, sort: bool = True) -> list[Station]:
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
return []
|
||||||
|
stations: list[Station] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for entry in payload:
|
||||||
|
station = station_from_entry(entry)
|
||||||
|
if station is None or station.id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(station.id)
|
||||||
|
stations.append(station)
|
||||||
|
if sort:
|
||||||
|
stations.sort(key=lambda station: station.title.casefold())
|
||||||
|
return stations
|
||||||
|
|
||||||
|
|
||||||
|
def _page_url(offset: int) -> str:
|
||||||
|
params = {**_PARAMS, "limit": str(PAGE_SIZE), "offset": str(offset)}
|
||||||
|
return f"{API_URL}?{urlencode(params)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_station_page(
|
||||||
|
client: httpx.AsyncClient, offset: int = 0
|
||||||
|
) -> StationPage:
|
||||||
|
"""One page of the directory, most-clicked first."""
|
||||||
|
try:
|
||||||
|
payload = await fetch_json(client, _page_url(offset))
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise RuntimeError(f"could not fetch the Radio Browser directory: {exc}") from None
|
||||||
|
|
||||||
|
stations = parse_stations(payload, sort=False)
|
||||||
|
rows = len(payload) if isinstance(payload, list) else 0
|
||||||
|
next_offset = offset + PAGE_SIZE if rows >= PAGE_SIZE else None
|
||||||
|
if offset == 0 and stations and isinstance(payload, list):
|
||||||
|
config.write_json(config.channels_cache_path("radiobrowser"), payload)
|
||||||
|
return StationPage(stations=stations, next_offset=next_offset)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_stations(client: httpx.AsyncClient) -> list[Station]:
|
||||||
|
page = await fetch_station_page(client, 0)
|
||||||
|
if not page.stations:
|
||||||
|
raise RuntimeError("Radio Browser returned no stations")
|
||||||
|
return page.stations
|
||||||
|
|
||||||
|
|
||||||
|
def load_cached_stations() -> list[Station]:
|
||||||
|
return parse_stations(
|
||||||
|
config.read_json(config.channels_cache_path("radiobrowser")), sort=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_field(
|
||||||
|
client: httpx.AsyncClient, query: str, field: str
|
||||||
|
) -> list[Station]:
|
||||||
|
params = {**_PARAMS, "limit": str(SEARCH_LIMIT), field: query}
|
||||||
|
payload = await fetch_json(client, f"{API_URL}?{urlencode(params)}")
|
||||||
|
return parse_stations(payload, sort=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def search_stations(client: httpx.AsyncClient, query: str) -> list[Station]:
|
||||||
|
"""Search the directory by name, tag, country and language.
|
||||||
|
|
||||||
|
The API has no single full-text search, so the query is sent once per field
|
||||||
|
and the results are merged. A station that matches a genre rarely has that
|
||||||
|
genre in its name, and vice versa. Fields that fail are ignored as long as
|
||||||
|
one of them still answers; only a complete failure is raised.
|
||||||
|
"""
|
||||||
|
text = query.strip()
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
|
||||||
|
batches = await asyncio.gather(
|
||||||
|
*(_search_field(client, text, field) for field in _SEARCH_FIELDS),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
stations: list[Station] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
failures = 0
|
||||||
|
for batch in batches:
|
||||||
|
if isinstance(batch, BaseException):
|
||||||
|
failures += 1
|
||||||
|
continue
|
||||||
|
for station in batch:
|
||||||
|
if station.id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(station.id)
|
||||||
|
stations.append(station)
|
||||||
|
|
||||||
|
if failures == len(batches):
|
||||||
|
raise RuntimeError("could not search the Radio Browser directory")
|
||||||
|
|
||||||
|
return stations
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_by_uuids(
|
||||||
|
client: httpx.AsyncClient, station_ids: list[str]
|
||||||
|
) -> list[Station]:
|
||||||
|
params = urlencode({"uuids": ",".join(station_ids)}, safe=",")
|
||||||
|
payload = await fetch_json(client, f"{BY_UUID_URL}?{params}")
|
||||||
|
return parse_stations(payload, sort=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_stations_by_ids(
|
||||||
|
client: httpx.AsyncClient, station_ids: list[str]
|
||||||
|
) -> list[Station]:
|
||||||
|
"""Look up specific stations, for favourites outside the first page."""
|
||||||
|
ids = list(
|
||||||
|
dict.fromkeys(
|
||||||
|
station_id.strip()
|
||||||
|
for station_id in station_ids
|
||||||
|
if _UUID_PATTERN.match(station_id.strip())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
batches = [
|
||||||
|
ids[index : index + LOOKUP_BATCH]
|
||||||
|
for index in range(0, len(ids), LOOKUP_BATCH)
|
||||||
|
]
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*(_fetch_by_uuids(client, batch) for batch in batches),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
stations: list[Station] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for batch in results:
|
||||||
|
if isinstance(batch, BaseException):
|
||||||
|
continue
|
||||||
|
for station in batch:
|
||||||
|
if station.id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(station.id)
|
||||||
|
stations.append(station)
|
||||||
|
return stations
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""A handful of directory entries for a cold offline start.
|
||||||
|
|
||||||
|
Radio Browser itself is the directory, but an offline start has no cache — so
|
||||||
|
this is ten stations whose relays have been up for years, one per taste.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ...models import Station
|
||||||
|
|
||||||
|
#: (id, title, country, genre tags, url, format, bitrate)
|
||||||
|
_STATIONS: tuple[tuple[str, str, str, str, str, str, str], ...] = (
|
||||||
|
("fallback-groovesalad", "SomaFM Groove Salad", "United States", "ambient|chill", "https://ice1.somafm.com/groovesalad-128-mp3", "mp3", "128"),
|
||||||
|
("fallback-radioparadise", "Radio Paradise", "United States", "eclectic|rock", "https://stream.radioparadise.com/flac", "flac", "1411"),
|
||||||
|
("fallback-fip", "FIP", "France", "eclectic|jazz", "https://icecast.radiofrance.fr/fip-midfi.mp3", "mp3", "128"),
|
||||||
|
("fallback-kexp", "KEXP", "United States", "alternative|indie", "https://kexp.streamguys1.com/kexp160.aac", "aac", "160"),
|
||||||
|
("fallback-nts1", "NTS Radio 1", "United Kingdom", "eclectic|electronic", "https://stream-relay-geo.ntslive.net/stream", "mp3", "128"),
|
||||||
|
("fallback-rinse", "Rinse UK", "United Kingdom", "house|electronic", "https://admin.stream.rinse.fm/proxy/rinse_uk/stream", "aacp", "128"),
|
||||||
|
("fallback-dublab", "dublab", "United States", "eclectic|experimental", "https://dublab.out.airtime.pro/dublab_a", "mp3", "192"),
|
||||||
|
("fallback-cashmere", "Cashmere Radio", "Germany", "experimental", "https://cashmereradio.out.airtime.pro/cashmereradio_a", "ogg", "192"),
|
||||||
|
("fallback-datafruits", "Datafruits", "United States", "electronic|experimental", "https://streampusher-relay.club/datafruits.mp3", "mp3", "128"),
|
||||||
|
("fallback-anonradio", "aNONradio", "United States", "eclectic|talk", "https://anonradio.net:8443/anonradio", "mp3", "192"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fallback_stations() -> list[Station]:
|
||||||
|
return [
|
||||||
|
Station(
|
||||||
|
id=station_id,
|
||||||
|
title=title,
|
||||||
|
description=country,
|
||||||
|
genre=genre,
|
||||||
|
playlists=[
|
||||||
|
{
|
||||||
|
"url": url,
|
||||||
|
"format": fmt,
|
||||||
|
"quality": "high",
|
||||||
|
"bitrate": bitrate,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
provider="radiobrowser",
|
||||||
|
)
|
||||||
|
for station_id, title, country, genre, url, fmt, bitrate in _STATIONS
|
||||||
|
]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Radio Browser's header hero: the directory's own name."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..wordmark import render
|
||||||
|
|
||||||
|
HERO: tuple[str, ...] = render("RADIOBROWSER")
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Radio Paradise: five hand-curated mixes, listener-supported since 2000.
|
||||||
|
|
||||||
|
Unlike the community stations, Radio Paradise publishes a proper now-playing
|
||||||
|
API for every mix, with album and cover art — so this provider is a small
|
||||||
|
catalogue with real track metadata rather than a single relay.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ... import config
|
||||||
|
from ...models import Song, Station
|
||||||
|
from ..base import RadioProvider
|
||||||
|
from ..wordmark import render
|
||||||
|
from . import api
|
||||||
|
|
||||||
|
__all__ = ["RadioParadiseProvider"]
|
||||||
|
|
||||||
|
#: How many tracks LAST PLAYED remembers per mix.
|
||||||
|
HISTORY_LIMIT = 20
|
||||||
|
|
||||||
|
|
||||||
|
class RadioParadiseProvider(RadioProvider):
|
||||||
|
id = "radioparadise"
|
||||||
|
name = "Radio Paradise"
|
||||||
|
homepage = "radioparadise.com"
|
||||||
|
brand = "RADIO PARADISE"
|
||||||
|
tagline = "listener-supported · curated mixes"
|
||||||
|
station_source = "api.radioparadise.com/now_playing"
|
||||||
|
history_source = "api.radioparadise.com (rolling)"
|
||||||
|
|
||||||
|
hero = render("PARADISE")
|
||||||
|
hero_lines = ("RADIO PARADISE", "LISTENER SUPPORTED", "PARADISE, CALIFORNIA")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_agent(self) -> str:
|
||||||
|
return f"{config.USER_AGENT}; +https://radioparadise.com"
|
||||||
|
|
||||||
|
def stations(self) -> list[Station]:
|
||||||
|
return [
|
||||||
|
Station(
|
||||||
|
id=station_id,
|
||||||
|
title=title,
|
||||||
|
description=f"Radio Paradise — {title}",
|
||||||
|
genre=genre,
|
||||||
|
playlists=api.playlists(names),
|
||||||
|
provider=self.id,
|
||||||
|
)
|
||||||
|
for station_id, _chan, title, genre, names in api.CHANNELS
|
||||||
|
]
|
||||||
|
|
||||||
|
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
|
||||||
|
return self.stations()
|
||||||
|
|
||||||
|
def load_cached_stations(self) -> list[Station]:
|
||||||
|
return self.stations()
|
||||||
|
|
||||||
|
def fallback_stations(self) -> list[Station]:
|
||||||
|
return self.stations()
|
||||||
|
|
||||||
|
def _songs_cache(self, station_id: str) -> Path:
|
||||||
|
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", station_id or self.id)
|
||||||
|
return config.SONGS_CACHE_DIR / f"{self.id}-{safe}.json"
|
||||||
|
|
||||||
|
async def fetch_history(
|
||||||
|
self, client: httpx.AsyncClient, station_id: str
|
||||||
|
) -> list[Song]:
|
||||||
|
chan = api.CHANNEL_BY_STATION.get(station_id)
|
||||||
|
if chan is None:
|
||||||
|
return self.load_cached_history(station_id)
|
||||||
|
|
||||||
|
current = await api.fetch_now(client, chan)
|
||||||
|
if current is None:
|
||||||
|
return self.load_cached_history(station_id)
|
||||||
|
|
||||||
|
history = self.load_cached_history(station_id)
|
||||||
|
if history and history[0].key() == current.key():
|
||||||
|
current.played_at = history[0].played_at
|
||||||
|
history = [current, *history[1:]]
|
||||||
|
else:
|
||||||
|
current.played_at = datetime.now(timezone.utc)
|
||||||
|
history = [current, *[song for song in history if song.key() != current.key()]]
|
||||||
|
history = history[:HISTORY_LIMIT]
|
||||||
|
|
||||||
|
config.write_json(
|
||||||
|
self._songs_cache(station_id),
|
||||||
|
{
|
||||||
|
"fetched": time.time(),
|
||||||
|
"songs": [
|
||||||
|
{
|
||||||
|
"title": song.title,
|
||||||
|
"artist": song.artist,
|
||||||
|
"album": song.album,
|
||||||
|
"art": song.art_url,
|
||||||
|
"played_at": song.played_at.isoformat()
|
||||||
|
if song.played_at
|
||||||
|
else "",
|
||||||
|
}
|
||||||
|
for song in history
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return history
|
||||||
|
|
||||||
|
def load_cached_history(self, station_id: str) -> list[Song]:
|
||||||
|
raw = config.read_json(self._songs_cache(station_id))
|
||||||
|
entries = raw.get("songs") if isinstance(raw, dict) else None
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
return []
|
||||||
|
songs: list[Song] = []
|
||||||
|
for entry in entries:
|
||||||
|
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("art") or "").strip(),
|
||||||
|
played_at=None,
|
||||||
|
)
|
||||||
|
if isinstance(entry.get("played_at"), str) and entry["played_at"]:
|
||||||
|
from ...models import parse_date
|
||||||
|
|
||||||
|
song.played_at = parse_date(entry["played_at"])
|
||||||
|
if song.title or song.artist:
|
||||||
|
songs.append(song)
|
||||||
|
return songs
|
||||||
|
|
||||||
|
def fallback_urls(self, station: Station) -> list[str]:
|
||||||
|
return [entry["url"] for entry in station.sorted_playlists()]
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Radio Paradise's public endpoints, and nothing but.
|
||||||
|
|
||||||
|
* ``https://api.radioparadise.com/api/now_playing?chan=N`` -> the current track
|
||||||
|
(artist, title, album, year, cover art, seconds remaining)
|
||||||
|
* ``https://stream.radioparadise.com/<name>`` -> the icecast relays
|
||||||
|
|
||||||
|
Radio Paradise curates five mixes; each is one relay and one channel number on
|
||||||
|
the now-playing API. The main mix carries lossy relays as well as FLAC; the
|
||||||
|
alternate mixes are FLAC only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ...models import Song
|
||||||
|
from ..base import fetch_json
|
||||||
|
|
||||||
|
NOW_URL = "https://api.radioparadise.com/api/now_playing?chan={chan}"
|
||||||
|
STREAM_URL = "https://stream.radioparadise.com/{name}"
|
||||||
|
|
||||||
|
FORMAT_LABELS = {"flac": ("flac", "highest"), "aac-320": ("aac", "high")}
|
||||||
|
|
||||||
|
#: (station id, channel, title, genre, relay names)
|
||||||
|
CHANNELS: tuple[tuple[str, int, str, str, tuple[str, ...]], ...] = (
|
||||||
|
(
|
||||||
|
"main",
|
||||||
|
0,
|
||||||
|
"The Main Mix",
|
||||||
|
"eclectic|rock",
|
||||||
|
("flac", "aac-320", "mp3-320", "mp3-128"),
|
||||||
|
),
|
||||||
|
("mellow", 1, "Mellow Mix", "chill|eclectic", ("mellow-flac",)),
|
||||||
|
("rock", 2, "RockIt!", "rock|alternative", ("rock-flac",)),
|
||||||
|
("global", 3, "The Globe", "world|eclectic", ("global-flac",)),
|
||||||
|
("beyond", 5, "Beyond...", "ambient|experimental", ("beyond-flac",)),
|
||||||
|
)
|
||||||
|
|
||||||
|
CHANNEL_BY_STATION = {station_id: chan for station_id, chan, *_ in CHANNELS}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_now(payload: Any) -> Song | None:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
artist = str(payload.get("artist") or "").strip()
|
||||||
|
title = str(payload.get("title") or "").strip()
|
||||||
|
if not (artist or title):
|
||||||
|
return None
|
||||||
|
return Song(
|
||||||
|
artist=artist,
|
||||||
|
title=title,
|
||||||
|
album=str(payload.get("album") or "").strip(),
|
||||||
|
art_url=str(payload.get("cover") or payload.get("cover_med") or "").strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_now(client: httpx.AsyncClient, chan: int) -> Song | None:
|
||||||
|
try:
|
||||||
|
payload = await fetch_json(client, NOW_URL.format(chan=chan), attempts=1)
|
||||||
|
except RuntimeError:
|
||||||
|
return None
|
||||||
|
return parse_now(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def playlists(names: tuple[str, ...]) -> list[dict[str, str]]:
|
||||||
|
"""The relay list for one mix, best first."""
|
||||||
|
entries: list[dict[str, str]] = []
|
||||||
|
for name in names:
|
||||||
|
fmt, quality = FORMAT_LABELS.get(name, (name.split("-")[0], "high"))
|
||||||
|
bitrate = ""
|
||||||
|
if fmt == "flac":
|
||||||
|
bitrate = "1411" # nominal CD-quality FLAC rate
|
||||||
|
elif name.startswith(("aac-", "mp3-")):
|
||||||
|
bitrate = name.split("-", 1)[1]
|
||||||
|
entries.append(
|
||||||
|
{
|
||||||
|
"url": STREAM_URL.format(name=name),
|
||||||
|
"format": fmt,
|
||||||
|
"quality": quality,
|
||||||
|
"bitrate": bitrate,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return entries
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Radio Paradise's header hero: the one word it is named after."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..wordmark import render
|
||||||
|
|
||||||
|
HERO: tuple[str, ...] = render("PARADISE")
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""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
|
||||||
|
from . import hero as hero_art
|
||||||
|
|
||||||
|
__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"
|
||||||
|
|
||||||
|
hero = hero_art.LOGO
|
||||||
|
hero_gradient = True
|
||||||
|
hero_lines = hero_art.TAGLINES
|
||||||
|
|
||||||
|
@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.
@@ -0,0 +1,19 @@
|
|||||||
|
"""SomaFM's header hero: the block-letter wordmark and the three lines beside it.
|
||||||
|
|
||||||
|
The glyphs come from the shared half-block font
|
||||||
|
(:mod:`beefy_player.providers.wordmark`); the header paints them with the app's
|
||||||
|
spectrum gradient (``hero_gradient``), so the logo takes on the terminal's own
|
||||||
|
colours instead of carrying RGB values around.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..wordmark import render
|
||||||
|
|
||||||
|
LOGO: tuple[str, ...] = render("SOMAFM")
|
||||||
|
|
||||||
|
TAGLINES: tuple[str, ...] = (
|
||||||
|
"SOMA FM RADIO",
|
||||||
|
"LISTENER SUPPORTED",
|
||||||
|
"SAN FRANCISCO",
|
||||||
|
)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""tilderadio: the tildeverse's community station.
|
||||||
|
|
||||||
|
One stream, one catalogue entry, a live DJ when a show is on and the shared
|
||||||
|
library rotation the rest of the time. Everything tilderadio-specific — the
|
||||||
|
``/api/now/`` schema, the ``now_playing`` + ``history[]`` pairing, the AzuraCast
|
||||||
|
mount list and the bundled fallback — lives in this package; the app only sees
|
||||||
|
:class:`TilderadioProvider`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ... import config
|
||||||
|
from ...models import Broadcast, Song, Station
|
||||||
|
from ..base import RadioProvider
|
||||||
|
from . import api, fallback
|
||||||
|
from . import hero as hero_art
|
||||||
|
|
||||||
|
__all__ = ["TilderadioProvider"]
|
||||||
|
|
||||||
|
|
||||||
|
class TilderadioProvider(RadioProvider):
|
||||||
|
id = "tilderadio"
|
||||||
|
name = "tilderadio"
|
||||||
|
homepage = "tilderadio.org"
|
||||||
|
brand = "TILDE RADIO"
|
||||||
|
tagline = "community radio · tildeverse"
|
||||||
|
station_source = "tilderadio.org/api/now/"
|
||||||
|
history_source = "now_playing + history[]"
|
||||||
|
|
||||||
|
# The station's own radio icon, converted to half blocks by
|
||||||
|
# ``tools/ansi_art.py``; the caption is the tildeverse's motto.
|
||||||
|
hero = hero_art.HERO
|
||||||
|
hero_lines = (
|
||||||
|
"TILDERADIO",
|
||||||
|
"STREAMED BY / FOR USERS OF THE",
|
||||||
|
"TILDEVERSE",
|
||||||
|
)
|
||||||
|
# One stream, so the left pane is a schedule instead of a station list.
|
||||||
|
has_schedule = True
|
||||||
|
# A shared library rotation is not a Bandcamp catalogue; skip the lookups.
|
||||||
|
bandcamp = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_agent(self) -> str:
|
||||||
|
# A small volunteer station; introduce ourselves and say where we are.
|
||||||
|
return f"{config.USER_AGENT}; +https://tilderadio.org"
|
||||||
|
|
||||||
|
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
|
||||||
|
return [await api.fetch_station(client)]
|
||||||
|
|
||||||
|
def load_cached_stations(self) -> list[Station]:
|
||||||
|
return api.load_cached_station()
|
||||||
|
|
||||||
|
def fallback_stations(self) -> list[Station]:
|
||||||
|
return fallback.fallback_stations()
|
||||||
|
|
||||||
|
async def fetch_history(
|
||||||
|
self, client: httpx.AsyncClient, station_id: str
|
||||||
|
) -> list[Song]:
|
||||||
|
return await api.fetch_songs(client, station_id)
|
||||||
|
|
||||||
|
def load_cached_history(self, station_id: str) -> list[Song]:
|
||||||
|
return api.load_cached_songs(station_id)
|
||||||
|
|
||||||
|
async def fetch_schedule(
|
||||||
|
self, client: httpx.AsyncClient
|
||||||
|
) -> list[Broadcast]:
|
||||||
|
return await api.fetch_schedule(client)
|
||||||
|
|
||||||
|
def load_cached_schedule(self) -> list[Broadcast]:
|
||||||
|
return api.load_cached_schedule()
|
||||||
|
|
||||||
|
def fallback_urls(self, station: Station) -> list[str]:
|
||||||
|
return fallback.known_stream_urls()
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
"""tilderadio's public endpoints, and nothing but.
|
||||||
|
|
||||||
|
* ``https://tilderadio.org/api/now/`` -> the station, listener count, the live
|
||||||
|
DJ, the current track and the last handful of tracks
|
||||||
|
* ``https://azuracast.tilderadio.org/api/nowplaying/tilderadio`` -> the mounts
|
||||||
|
(mp3/ogg, bitrates) behind the stream, so we can offer the best one first
|
||||||
|
|
||||||
|
tilderadio is a single-station catalogue: one community stream for the whole
|
||||||
|
tildeverse, run on an AzuraCast instance. That makes the "station list" a list
|
||||||
|
of one, and it means ``/now/`` is both the catalogue *and* the history feed —
|
||||||
|
the current track arrives as ``now_playing``, the previous ones as
|
||||||
|
``history[]``. Everything here is forgiving: the station is better served by a
|
||||||
|
slightly degraded panel than by a crash.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ... import config
|
||||||
|
from ...models import (
|
||||||
|
Broadcast,
|
||||||
|
Song,
|
||||||
|
Station,
|
||||||
|
parse_date,
|
||||||
|
parse_last_playing,
|
||||||
|
to_int,
|
||||||
|
)
|
||||||
|
from ..base import fetch_json, quote_station_id
|
||||||
|
|
||||||
|
PROVIDER_ID = "tilderadio"
|
||||||
|
API_ROOT = "https://tilderadio.org/api"
|
||||||
|
NOW_URL = f"{API_ROOT}/now/"
|
||||||
|
SCHEDULE_URL = f"{API_ROOT}/schedule/"
|
||||||
|
AZURACAST_URL = "https://azuracast.tilderadio.org/api/nowplaying/{station}"
|
||||||
|
|
||||||
|
DEFAULT_STATION_ID = "tilderadio"
|
||||||
|
STATION_IMAGE = "https://tilderadio.org/logos/tilderadio.png"
|
||||||
|
DESCRIPTION = "community radio for the tildeverse"
|
||||||
|
GENRE = "community|eclectic"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# payload -> Station / Song
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _artist_title(entry: dict[str, Any]) -> tuple[str, str]:
|
||||||
|
"""AzuraCast sends ``artist``/``title`` *or* one ``"Artist - Title"`` blob."""
|
||||||
|
artist = str(entry.get("artist") or "").strip()
|
||||||
|
title = str(entry.get("title") or "").strip()
|
||||||
|
if not artist:
|
||||||
|
text = str(entry.get("text") or "").strip()
|
||||||
|
split = parse_last_playing(text) if text else None
|
||||||
|
if split is not None:
|
||||||
|
artist, title = split.artist or artist, split.title or title
|
||||||
|
return artist, title
|
||||||
|
|
||||||
|
|
||||||
|
def song_from_track(entry: Any) -> Song | None:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
return None
|
||||||
|
artist, title = _artist_title(entry)
|
||||||
|
if not (artist or title):
|
||||||
|
return None
|
||||||
|
return Song(
|
||||||
|
title=title,
|
||||||
|
artist=artist,
|
||||||
|
album=str(entry.get("album") or "").strip(),
|
||||||
|
art_url=str(entry.get("art") or entry.get("albumArt") or "").strip(),
|
||||||
|
played_at=parse_date(entry.get("played_at") or entry.get("date")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_tracks(entries: Any) -> list[Song]:
|
||||||
|
"""Newest first, and the same track only once even if the feed repeats it."""
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
return []
|
||||||
|
songs: list[Song] = []
|
||||||
|
seen: set[tuple[str, Any]] = set()
|
||||||
|
for entry in entries:
|
||||||
|
song = song_from_track(entry)
|
||||||
|
if song is None:
|
||||||
|
continue
|
||||||
|
marker = (song.key(), song.played_at)
|
||||||
|
if marker in seen:
|
||||||
|
continue
|
||||||
|
seen.add(marker)
|
||||||
|
songs.append(song)
|
||||||
|
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_from_now(payload: Any) -> list[Song]:
|
||||||
|
"""``now_playing`` followed by ``history[]``, as one newest-first list."""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return []
|
||||||
|
entries: list[Any] = [payload.get("now_playing")]
|
||||||
|
history = payload.get("history")
|
||||||
|
if isinstance(history, list):
|
||||||
|
entries.extend(history)
|
||||||
|
return parse_tracks(entries)
|
||||||
|
|
||||||
|
|
||||||
|
def _quality(bitrate: int) -> str:
|
||||||
|
if bitrate >= 256:
|
||||||
|
return "highest"
|
||||||
|
if bitrate >= 128:
|
||||||
|
return "high"
|
||||||
|
return "low" if bitrate else ""
|
||||||
|
|
||||||
|
|
||||||
|
def playlists_from_mounts(payload: Any) -> list[dict[str, str]]:
|
||||||
|
"""AzuraCast's mount list -> the playlist dicts :class:`Station` speaks."""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return []
|
||||||
|
info = payload.get("station")
|
||||||
|
mounts = info.get("mounts") if isinstance(info, dict) else None
|
||||||
|
if not isinstance(mounts, list):
|
||||||
|
return []
|
||||||
|
playlists: list[dict[str, str]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for mount in mounts:
|
||||||
|
if not isinstance(mount, dict):
|
||||||
|
continue
|
||||||
|
url = mount.get("url")
|
||||||
|
if not isinstance(url, str) or not url.startswith(("http://", "https://")):
|
||||||
|
continue
|
||||||
|
if url in seen:
|
||||||
|
continue
|
||||||
|
seen.add(url)
|
||||||
|
bitrate = to_int(mount.get("bitrate"))
|
||||||
|
playlists.append(
|
||||||
|
{
|
||||||
|
"url": url,
|
||||||
|
"format": str(mount.get("format") or "").strip().lower(),
|
||||||
|
"quality": _quality(bitrate),
|
||||||
|
"bitrate": str(bitrate) if bitrate else "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return playlists
|
||||||
|
|
||||||
|
|
||||||
|
def _playlists_from_listen_url(info: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
url = info.get("listen_url")
|
||||||
|
if not isinstance(url, str) or not url.startswith(("http://", "https://")):
|
||||||
|
return []
|
||||||
|
return [{"url": url, "format": "ogg", "quality": "high", "bitrate": ""}]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_live(payload: dict[str, Any]) -> bool:
|
||||||
|
live = payload.get("live")
|
||||||
|
if isinstance(live, dict):
|
||||||
|
return bool(live.get("is_live"))
|
||||||
|
return bool(payload.get("is_live"))
|
||||||
|
|
||||||
|
|
||||||
|
def _live_dj(payload: dict[str, Any]) -> str:
|
||||||
|
"""Only name a DJ when the station says someone is actually on air."""
|
||||||
|
if not _is_live(payload):
|
||||||
|
return ""
|
||||||
|
live = payload.get("live")
|
||||||
|
if isinstance(live, dict):
|
||||||
|
return str(live.get("name") or live.get("dj") or "").strip()
|
||||||
|
return str(payload.get("dj") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def station_from_now(
|
||||||
|
payload: Any, playlists: Iterable[Any] = ()
|
||||||
|
) -> Station | None:
|
||||||
|
"""The whole catalogue, from one ``/api/now/`` response."""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
info = payload.get("station")
|
||||||
|
if not isinstance(info, dict):
|
||||||
|
return None
|
||||||
|
station_id = (
|
||||||
|
str(info.get("shortcode") or info.get("name") or DEFAULT_STATION_ID).strip()
|
||||||
|
or DEFAULT_STATION_ID
|
||||||
|
)
|
||||||
|
|
||||||
|
known = [dict(entry) for entry in playlists if isinstance(entry, dict)]
|
||||||
|
station = Station(
|
||||||
|
id=station_id,
|
||||||
|
title=str(info.get("name") or station_id).strip() or DEFAULT_STATION_ID,
|
||||||
|
description=str(info.get("description") or "").strip() or DESCRIPTION,
|
||||||
|
genre=GENRE,
|
||||||
|
dj=_live_dj(payload),
|
||||||
|
presenter="" if _is_live(payload) else "auto dj · library rotation",
|
||||||
|
image=STATION_IMAGE,
|
||||||
|
listeners=to_int(payload.get("listeners")),
|
||||||
|
playlists=known or _playlists_from_listen_url(info),
|
||||||
|
provider=PROVIDER_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
current = payload.get("now_playing")
|
||||||
|
if isinstance(current, dict):
|
||||||
|
song = song_from_track(current)
|
||||||
|
if song is not None:
|
||||||
|
station.now_playing = song
|
||||||
|
station.last_playing = str(current.get("text") or "").strip() or (
|
||||||
|
f"{song.artist} - {song.title}" if song.artist else song.title
|
||||||
|
)
|
||||||
|
return station
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# schedule
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def parse_schedule(payload: Any) -> list[Broadcast]:
|
||||||
|
"""``/api/schedule/`` -> broadcasts, soonest first."""
|
||||||
|
entries = payload.get("schedule") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
return []
|
||||||
|
broadcasts: list[Broadcast] = []
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
name = str(entry.get("name") or entry.get("slug") or "").strip()
|
||||||
|
start = parse_date(entry.get("start_ts") or entry.get("start"))
|
||||||
|
end = parse_date(entry.get("end_ts") or entry.get("end"))
|
||||||
|
if not name or start is None:
|
||||||
|
continue
|
||||||
|
broadcasts.append(Broadcast(name=name, dj=name, start=start, end=end))
|
||||||
|
broadcasts.sort(key=lambda item: item.start or datetime.now(timezone.utc))
|
||||||
|
return broadcasts
|
||||||
|
|
||||||
|
|
||||||
|
def live_broadcast(payload: Any) -> Broadcast | None:
|
||||||
|
"""The show that is on air right now, if the feed says there is one."""
|
||||||
|
if not isinstance(payload, dict) or not payload.get("is_live"):
|
||||||
|
return None
|
||||||
|
name = _live_dj(payload) or "live broadcast"
|
||||||
|
return Broadcast(
|
||||||
|
name=name,
|
||||||
|
dj=name,
|
||||||
|
start=parse_date(payload.get("broadcast_start")),
|
||||||
|
end=parse_date(payload.get("broadcast_end")),
|
||||||
|
live=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# caching
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def station_cache_path() -> Path:
|
||||||
|
return config.channels_cache_path(PROVIDER_ID)
|
||||||
|
|
||||||
|
|
||||||
|
def _songs_cache_path(station_id: str) -> Path:
|
||||||
|
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", station_id or DEFAULT_STATION_ID)
|
||||||
|
return config.SONGS_CACHE_DIR / f"{PROVIDER_ID}-{safe}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _songs_to_cache(songs: Iterable[Song]) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"title": song.title,
|
||||||
|
"artist": song.artist,
|
||||||
|
"album": song.album,
|
||||||
|
"art": song.art_url,
|
||||||
|
"played_at": song.played_at.isoformat() if song.played_at else "",
|
||||||
|
}
|
||||||
|
for song in songs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# fetching
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_mounts(
|
||||||
|
client: httpx.AsyncClient, station_id: str
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
url = AZURACAST_URL.format(station=quote_station_id(station_id))
|
||||||
|
try:
|
||||||
|
payload = await fetch_json(client, url, attempts=1)
|
||||||
|
except RuntimeError:
|
||||||
|
return []
|
||||||
|
return playlists_from_mounts(payload)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_station(client: httpx.AsyncClient) -> Station:
|
||||||
|
try:
|
||||||
|
payload = await fetch_json(client, NOW_URL)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise RuntimeError(f"could not fetch tilderadio now-playing: {exc}") from None
|
||||||
|
|
||||||
|
station = station_from_now(payload)
|
||||||
|
if station is None:
|
||||||
|
raise RuntimeError("tilderadio returned no station data")
|
||||||
|
|
||||||
|
# Bitrates and the mp3 mount only come from AzuraCast; losing them is not
|
||||||
|
# a reason to lose the station.
|
||||||
|
playlists = await _fetch_mounts(client, station.id)
|
||||||
|
if playlists:
|
||||||
|
station.playlists = playlists
|
||||||
|
|
||||||
|
config.write_json(
|
||||||
|
station_cache_path(),
|
||||||
|
{"fetched": time.time(), "now": payload, "mounts": station.playlists},
|
||||||
|
)
|
||||||
|
return station
|
||||||
|
|
||||||
|
|
||||||
|
def load_cached_station() -> list[Station]:
|
||||||
|
raw = config.read_json(station_cache_path())
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return []
|
||||||
|
mounts = raw.get("mounts")
|
||||||
|
station = station_from_now(
|
||||||
|
raw.get("now"), mounts if isinstance(mounts, list) else []
|
||||||
|
)
|
||||||
|
return [station] if station is not None else []
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_songs(client: httpx.AsyncClient, station_id: str) -> list[Song]:
|
||||||
|
try:
|
||||||
|
payload = await fetch_json(client, NOW_URL, attempts=1)
|
||||||
|
except RuntimeError:
|
||||||
|
return load_cached_songs(station_id)
|
||||||
|
|
||||||
|
songs = songs_from_now(payload)
|
||||||
|
if not songs:
|
||||||
|
return load_cached_songs(station_id)
|
||||||
|
config.write_json(
|
||||||
|
_songs_cache_path(station_id),
|
||||||
|
{"fetched": time.time(), "songs": _songs_to_cache(songs)},
|
||||||
|
)
|
||||||
|
return songs
|
||||||
|
|
||||||
|
|
||||||
|
def load_cached_songs(station_id: str) -> list[Song]:
|
||||||
|
raw = config.read_json(_songs_cache_path(station_id))
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
return parse_tracks(raw.get("songs"))
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_cache_path() -> Path:
|
||||||
|
return config.schedule_cache_path(PROVIDER_ID)
|
||||||
|
|
||||||
|
|
||||||
|
def load_cached_schedule() -> list[Broadcast]:
|
||||||
|
raw = config.read_json(_schedule_cache_path())
|
||||||
|
entries = raw.get("schedule") if isinstance(raw, dict) else None
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
return []
|
||||||
|
return parse_schedule({"schedule": entries})
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_schedule(client: httpx.AsyncClient) -> list[Broadcast]:
|
||||||
|
"""What is on air now (if anything) followed by what is coming up."""
|
||||||
|
live: Broadcast | None = None
|
||||||
|
try:
|
||||||
|
now_payload = await fetch_json(client, NOW_URL, attempts=1)
|
||||||
|
except RuntimeError:
|
||||||
|
now_payload = None
|
||||||
|
live = live_broadcast(now_payload)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = await fetch_json(client, SCHEDULE_URL, attempts=1)
|
||||||
|
except RuntimeError:
|
||||||
|
upcoming = load_cached_schedule()
|
||||||
|
else:
|
||||||
|
upcoming = parse_schedule(payload)
|
||||||
|
config.write_json(
|
||||||
|
_schedule_cache_path(),
|
||||||
|
{
|
||||||
|
"fetched": time.time(),
|
||||||
|
"schedule": [
|
||||||
|
{
|
||||||
|
"name": item.name,
|
||||||
|
"start": item.start.isoformat() if item.start else "",
|
||||||
|
"end": item.end.isoformat() if item.end else "",
|
||||||
|
}
|
||||||
|
for item in upcoming
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
upcoming = [
|
||||||
|
item
|
||||||
|
for item in upcoming
|
||||||
|
if not item.live and (item.end or item.start or now) >= now
|
||||||
|
]
|
||||||
|
return ([live] if live is not None else []) + upcoming
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Bundled station, so a cold offline start still makes sense.
|
||||||
|
|
||||||
|
tilderadio is a single-station catalogue — one community stream for the whole
|
||||||
|
tildeverse — so "the bundled station list" is that one station. The mounts
|
||||||
|
below are the ones the AzuraCast instance has served for years; the live list
|
||||||
|
from ``/api/now/`` always wins, and the URLs are additionally probed by the
|
||||||
|
player's normal failover.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# (url, format, quality, bitrate)
|
||||||
|
_MOUNTS: tuple[tuple[str, str, str, str], ...] = (
|
||||||
|
("https://azuracast.tilderadio.org/listen/tilderadio/radio.mp3", "mp3", "high", "192"),
|
||||||
|
("https://azuracast.tilderadio.org/listen/tilderadio/320k.mp3", "mp3", "highest", "320"),
|
||||||
|
("https://azuracast.tilderadio.org/listen/tilderadio/radio.ogg", "ogg", "high", "192"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fallback_payload() -> dict[str, Any]:
|
||||||
|
"""A ``/api/now/``-shaped payload, so the normal parser can eat it."""
|
||||||
|
return {
|
||||||
|
"api_version": 1,
|
||||||
|
"status": "ok",
|
||||||
|
"station": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "tilderadio",
|
||||||
|
"shortcode": "tilderadio",
|
||||||
|
"listen_url": _MOUNTS[2][0],
|
||||||
|
},
|
||||||
|
"is_live": False,
|
||||||
|
"dj": None,
|
||||||
|
"listeners": 0,
|
||||||
|
"now_playing": None,
|
||||||
|
"history": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fallback_playlists() -> list[dict[str, str]]:
|
||||||
|
return [
|
||||||
|
{"url": url, "format": fmt, "quality": quality, "bitrate": bitrate}
|
||||||
|
for url, fmt, quality, bitrate in _MOUNTS
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def known_stream_urls() -> list[str]:
|
||||||
|
return [url for url, _format, _quality, _bitrate in _MOUNTS]
|
||||||
|
|
||||||
|
|
||||||
|
def fallback_stations() -> list[Any]:
|
||||||
|
from .api import station_from_now
|
||||||
|
|
||||||
|
station = station_from_now(fallback_payload(), fallback_playlists())
|
||||||
|
return [station] if station is not None else []
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""tilderadio's header hero: its name in the shared half-block font.
|
||||||
|
|
||||||
|
No colours of its own — the wordmark inherits the terminal's default
|
||||||
|
foreground, so it looks like part of the chrome rather than a logo pasted in.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..wordmark import render
|
||||||
|
|
||||||
|
HERO: tuple[str, ...] = render("TILDERADIO")
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""A half-block wordmark font for the header hero.
|
||||||
|
|
||||||
|
Every shipped catalogue draws its brand the same way: four-column capitals,
|
||||||
|
six pixels tall, emitted as three rows of half blocks (``▀``/``▄``/``█``) — the
|
||||||
|
same shape as the original SomaFM logo. Catalogues differ only in the word
|
||||||
|
they spell and the ANSI colours they paint it with, so the font lives here and
|
||||||
|
each provider's ``hero.py`` is one line of data.
|
||||||
|
|
||||||
|
Glyphs are 6x4 bitmaps (``M`` is 6x5). Two pixel rows become one text row, so
|
||||||
|
a wordmark is always three rows tall and the header keeps a fixed height.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
#: 6-row bitmap per capital; all rows of one glyph are the same width.
|
||||||
|
GLYPHS: dict[str, tuple[str, ...]] = {
|
||||||
|
"A": (".##.", "#..#", "####", "#..#", "#..#", "#..#"),
|
||||||
|
"B": ("###.", "#..#", "###.", "#..#", "#..#", "###."),
|
||||||
|
"C": (".##.", "#..#", "#...", "#...", "#..#", ".##."),
|
||||||
|
"D": ("###.", "#..#", "#..#", "#..#", "#..#", "###."),
|
||||||
|
"E": ("####", "#...", "###.", "#...", "#...", "####"),
|
||||||
|
"F": ("####", "#...", "###.", "#...", "#...", "#..."),
|
||||||
|
"G": (".##.", "#..#", "#...", "#.##", "#..#", ".##."),
|
||||||
|
"H": ("#..#", "#..#", "####", "#..#", "#..#", "#..#"),
|
||||||
|
"I": ("###", ".#.", ".#.", ".#.", ".#.", "###"),
|
||||||
|
"K": ("#..#", "#.#.", "##..", "##..", "#.#.", "#..#"),
|
||||||
|
"L": ("#...", "#...", "#...", "#...", "#...", "####"),
|
||||||
|
# 5 wide on purpose: the M is the odd one out in the original wordmark.
|
||||||
|
"M": ("#...#", "##.##", "#.#.#", "#.#.#", "#...#", "#...#"),
|
||||||
|
"N": ("#..#", "##.#", "##.#", "#.##", "#..#", "#..#"),
|
||||||
|
"O": (".##.", "#..#", "#..#", "#..#", "#..#", ".##."),
|
||||||
|
"P": ("###.", "#..#", "###.", "#...", "#...", "#..."),
|
||||||
|
"R": ("###.", "#..#", "###.", "#.#.", "#..#", "#..#"),
|
||||||
|
"S": (".###", "#...", "###.", "...#", "...#", "###."),
|
||||||
|
"T": ("###", ".#.", ".#.", ".#.", ".#.", ".#."),
|
||||||
|
"U": ("#..#", "#..#", "#..#", "#..#", "#..#", ".##."),
|
||||||
|
"V": ("#..#", "#..#", "#..#", "#..#", ".##.", ".##."),
|
||||||
|
"W": ("#...#", "#...#", "#.#.#", "#.#.#", "##.##", "#...#"),
|
||||||
|
"Y": ("#..#", "#..#", ".##.", "..#.", "..#.", "..#."),
|
||||||
|
}
|
||||||
|
|
||||||
|
ROWS = 3
|
||||||
|
_GAP = " "
|
||||||
|
|
||||||
|
|
||||||
|
def _cell(top: str, bottom: str) -> str:
|
||||||
|
if top == "#" and bottom == "#":
|
||||||
|
return "█"
|
||||||
|
if top == "#":
|
||||||
|
return "▀"
|
||||||
|
if bottom == "#":
|
||||||
|
return "▄"
|
||||||
|
return " "
|
||||||
|
|
||||||
|
|
||||||
|
def _glyph_rows(char: str) -> tuple[str, ...]:
|
||||||
|
bitmap = GLYPHS.get(char)
|
||||||
|
if bitmap is None:
|
||||||
|
raise KeyError(f"no wordmark glyph for {char!r}")
|
||||||
|
return tuple(
|
||||||
|
"".join(_cell(bitmap[row * 2][col], bitmap[row * 2 + 1][col]) for col in range(len(bitmap[0])))
|
||||||
|
for row in range(ROWS)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render(text: str, styles: Sequence[str | None] | None = None) -> tuple[str, ...]:
|
||||||
|
"""Spell ``text`` as three rows of half blocks.
|
||||||
|
|
||||||
|
``styles`` gives one rich-markup style per character (``None`` leaves it in
|
||||||
|
the terminal's default colour). Contiguous runs of the same style share a
|
||||||
|
markup span, so ``render("BASSDRIVE", ...)`` paints the first four letters
|
||||||
|
and the last five separately.
|
||||||
|
"""
|
||||||
|
if styles is not None and len(styles) != len(text):
|
||||||
|
raise ValueError("styles must provide one entry per character")
|
||||||
|
|
||||||
|
glyphs = [_glyph_rows(char.upper()) for char in text]
|
||||||
|
rows: list[str] = []
|
||||||
|
for row in range(ROWS):
|
||||||
|
cells: list[tuple[str | None, str]] = []
|
||||||
|
for index, glyph in enumerate(glyphs):
|
||||||
|
style = styles[index] if styles is not None else None
|
||||||
|
if index:
|
||||||
|
cells.append((style, _GAP))
|
||||||
|
cells.extend((style, char) for char in glyph[row])
|
||||||
|
while cells and cells[-1][1] == " ":
|
||||||
|
cells.pop()
|
||||||
|
|
||||||
|
segments: list[tuple[str | None, str]] = []
|
||||||
|
for style, char in cells:
|
||||||
|
if segments and segments[-1][0] == style:
|
||||||
|
segments[-1] = (style, segments[-1][1] + char)
|
||||||
|
else:
|
||||||
|
segments.append((style, char))
|
||||||
|
rows.append(
|
||||||
|
"".join(f"[{style}]{body}[/]" if style else body for style, body in segments)
|
||||||
|
)
|
||||||
|
return tuple(rows)
|
||||||
@@ -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 {
|
||||||
@@ -76,9 +76,14 @@ StationList {
|
|||||||
color: ansi_default;
|
color: ansi_default;
|
||||||
width: 1fr;
|
width: 1fr;
|
||||||
height: 1fr;
|
height: 1fr;
|
||||||
border: round ansi_bright_black;
|
background: ansi_default;
|
||||||
border-title-color: ansi_default;
|
}
|
||||||
padding: 0 1;
|
|
||||||
|
SchedulePanel {
|
||||||
|
color: ansi_default;
|
||||||
|
width: 1fr;
|
||||||
|
height: 1fr;
|
||||||
|
display: none;
|
||||||
background: ansi_default;
|
background: ansi_default;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,8 +91,30 @@ StagePanel {
|
|||||||
color: ansi_default;
|
color: ansi_default;
|
||||||
layout: vertical;
|
layout: vertical;
|
||||||
width: 1fr;
|
width: 1fr;
|
||||||
border: round ansi_bright_black;
|
background: ansi_default;
|
||||||
border-title-color: ansi_default;
|
}
|
||||||
|
|
||||||
|
#stage_frame_top,
|
||||||
|
#stage_frame_bottom {
|
||||||
|
height: 1;
|
||||||
|
background: ansi_default;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stage_middle {
|
||||||
|
height: 1fr;
|
||||||
|
background: ansi_default;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stage_frame_left,
|
||||||
|
#stage_frame_right {
|
||||||
|
width: 1;
|
||||||
|
height: 1fr;
|
||||||
|
background: ansi_default;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stage_body {
|
||||||
|
width: 1fr;
|
||||||
|
height: 1fr;
|
||||||
padding: 0 1;
|
padding: 0 1;
|
||||||
background: ansi_default;
|
background: ansi_default;
|
||||||
}
|
}
|
||||||
@@ -132,9 +159,6 @@ SpectrumPanel {
|
|||||||
color: ansi_default;
|
color: ansi_default;
|
||||||
height: 2fr;
|
height: 2fr;
|
||||||
min-height: 6;
|
min-height: 6;
|
||||||
border: round ansi_bright_black;
|
|
||||||
border-title-color: ansi_default;
|
|
||||||
padding: 0 1;
|
|
||||||
background: ansi_default;
|
background: ansi_default;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,12 +175,40 @@ ModalScreen {
|
|||||||
background: ansi_default;
|
background: ansi_default;
|
||||||
}
|
}
|
||||||
|
|
||||||
#help {
|
#providers {
|
||||||
color: ansi_default;
|
color: ansi_default;
|
||||||
width: 78;
|
width: 66;
|
||||||
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
max-height: 100%;
|
||||||
border: round ansi_bright_black;
|
border: round ansi_bright_black;
|
||||||
border-title-color: ansi_default;
|
border-title-color: ansi_default;
|
||||||
padding: 1 2;
|
padding: 1 2;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-size-vertical: 1;
|
||||||
|
scrollbar-color: ansi_bright_black;
|
||||||
|
scrollbar-background: ansi_default;
|
||||||
|
background: ansi_default;
|
||||||
|
}
|
||||||
|
|
||||||
|
#providers-body {
|
||||||
|
color: ansi_default;
|
||||||
|
height: auto;
|
||||||
|
background: ansi_default;
|
||||||
|
}
|
||||||
|
|
||||||
|
#help {
|
||||||
|
color: ansi_default;
|
||||||
|
width: 78;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: 100%;
|
||||||
|
border: round ansi_bright_black;
|
||||||
|
border-title-color: ansi_default;
|
||||||
|
padding: 1 2;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-size-vertical: 1;
|
||||||
|
scrollbar-color: ansi_bright_black;
|
||||||
|
scrollbar-background: ansi_default;
|
||||||
background: ansi_default;
|
background: ansi_default;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Custom Textual widgets for Beefy Player."""
|
||||||
@@ -22,13 +22,13 @@ from textual.strip import Strip
|
|||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
|
|
||||||
from .. import artwork
|
from .. import artwork
|
||||||
from .common import art_lines
|
from .common import active_stops, art_lines
|
||||||
|
|
||||||
_NULL_STYLE = Style()
|
_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))
|
||||||
@@ -308,19 +308,26 @@ class ArtPane(Widget):
|
|||||||
self._last_data: bytes | None = None
|
self._last_data: bytes | None = None
|
||||||
self._last_size: tuple[int, int] = (0, 0)
|
self._last_size: tuple[int, int] = (0, 0)
|
||||||
self._mosaic_key = ""
|
self._mosaic_key = ""
|
||||||
|
#: True while a modal is up: the inline image would sit over the menu.
|
||||||
|
self._suspended = False
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# mosaic background
|
# mosaic background
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
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("")
|
||||||
width = max(1, self.size.width)
|
width = max(1, self.size.width)
|
||||||
height = max(1, self.size.height)
|
height = max(1, self.size.height)
|
||||||
return Group(*art_lines(_art_key(app), width, height))
|
if artwork.supported() and app.artwork_bytes is not None:
|
||||||
|
# A real image occupies this box (letterboxed, so not all of it).
|
||||||
|
# Leave the space blank: the patterned mosaic behind a scaled
|
||||||
|
# picture reads as noise around its edges.
|
||||||
|
return Group(*[Text(" " * width) for _ in range(height)])
|
||||||
|
return Group(*art_lines(_art_key(app), width, height, active_stops(app)))
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# image overlay
|
# image overlay
|
||||||
@@ -337,11 +344,29 @@ class ArtPane(Widget):
|
|||||||
def on_unmount(self) -> None:
|
def on_unmount(self) -> None:
|
||||||
self._drop_image_widget()
|
self._drop_image_widget()
|
||||||
|
|
||||||
|
def suspend(self) -> None:
|
||||||
|
"""Take the inline image down so a modal is not drawn underneath it.
|
||||||
|
|
||||||
|
Kitty and iTerm2 paint images into cells the terminal owns; Textual
|
||||||
|
repainting the menu over them does not remove them, so the artwork has
|
||||||
|
to be deleted and re-emitted rather than covered.
|
||||||
|
"""
|
||||||
|
self._suspended = True
|
||||||
|
self._drop_image_widget()
|
||||||
|
self.refresh(layout=True)
|
||||||
|
|
||||||
|
def resume(self) -> None:
|
||||||
|
"""Put the artwork back after the modal closes."""
|
||||||
|
self._suspended = False
|
||||||
|
self.sync_image(force=True)
|
||||||
|
|
||||||
def sync_image(self, *, force: bool = False) -> None:
|
def sync_image(self, *, force: bool = False) -> None:
|
||||||
# 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()
|
||||||
|
if self._suspended:
|
||||||
|
return
|
||||||
|
|
||||||
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)
|
||||||
@@ -19,23 +19,20 @@ from zoneinfo import ZoneInfo
|
|||||||
from rich.cells import cell_len
|
from rich.cells import cell_len
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
|
from ..palettes import (
|
||||||
|
DEFAULT_PALETTE,
|
||||||
|
palette_stops,
|
||||||
|
)
|
||||||
|
|
||||||
LOCALTIME_PATH = "/etc/localtime"
|
LOCALTIME_PATH = "/etc/localtime"
|
||||||
_UTC_NAMES = {"UTC", "UCT", "GMT", "ETC/UTC", "ETC/UCT", "ETC/GMT", "Z"}
|
_UTC_NAMES = {"UTC", "UCT", "GMT", "ETC/UTC", "ETC/UCT", "ETC/GMT", "Z"}
|
||||||
|
|
||||||
LEVELS = " ▁▂▃▄▅▆▇█"
|
LEVELS = " ▁▂▃▄▅▆▇█"
|
||||||
|
|
||||||
# Bottom-to-top spectrum colours, picked from the terminal's stock ANSI palette.
|
def active_stops(app: object) -> tuple[str, ...]:
|
||||||
SPECTRUM_STOPS = (
|
"""The colour ladder the app is currently drawing with."""
|
||||||
"cyan",
|
return palette_stops(getattr(app, "palette", DEFAULT_PALETTE))
|
||||||
"blue",
|
|
||||||
"magenta",
|
|
||||||
"bright_magenta",
|
|
||||||
"yellow",
|
|
||||||
"bright_yellow",
|
|
||||||
"red",
|
|
||||||
)
|
|
||||||
|
|
||||||
ART_STOPS = ("blue", "magenta", "bright_magenta", "yellow", "cyan", "green")
|
|
||||||
|
|
||||||
STATE_STYLE = {
|
STATE_STYLE = {
|
||||||
"playing": ("●", "green"),
|
"playing": ("●", "green"),
|
||||||
@@ -182,7 +179,9 @@ def state_glyph(state: str) -> tuple[str, str]:
|
|||||||
return STATE_STYLE.get(state, STATE_STYLE["stopped"])
|
return STATE_STYLE.get(state, STATE_STYLE["stopped"])
|
||||||
|
|
||||||
|
|
||||||
def art_lines(key: str, width: int, height: int) -> list[Text]:
|
def art_lines(
|
||||||
|
key: str, width: int, height: int, stops: tuple[str, ...]
|
||||||
|
) -> list[Text]:
|
||||||
"""Procedural album art as a solid ANSI-colour mosaic.
|
"""Procedural album art as a solid ANSI-colour mosaic.
|
||||||
|
|
||||||
Filled block glyphs are used (never cell backgrounds), so the cover is
|
Filled block glyphs are used (never cell backgrounds), so the cover is
|
||||||
@@ -200,7 +199,7 @@ def art_lines(key: str, width: int, height: int) -> list[Text]:
|
|||||||
nx = column / max(1, width - 1)
|
nx = column / max(1, width - 1)
|
||||||
wave = 0.5 + 0.5 * math.sin(nx * 2.9 + ny * 2.1 + (seed % 11))
|
wave = 0.5 + 0.5 * math.sin(nx * 2.9 + ny * 2.1 + (seed % 11))
|
||||||
position = (nx * 0.55 + (1.0 - ny) * 0.30 + wave * 0.15) % 1.0
|
position = (nx * 0.55 + (1.0 - ny) * 0.30 + wave * 0.15) % 1.0
|
||||||
colour = gradient(ART_STOPS, position)
|
colour = gradient(stops, position)
|
||||||
# Mostly solid blocks with a little texture, like a tiny cover.
|
# Mostly solid blocks with a little texture, like a tiny cover.
|
||||||
glyph = "█" if ((seed + column * 7 + row * 11) % 17) not in (0, 5) else "▓"
|
glyph = "█" if ((seed + column * 7 + row * 11) % 17) not in (0, 5) else "▓"
|
||||||
line.append(glyph, style=colour)
|
line.append(glyph, style=colour)
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""Pane frames: one grey box whose corners glow in the active palette.
|
||||||
|
|
||||||
|
The panes draw their own frames instead of using Textual's ``border`` so the
|
||||||
|
corners can be coloured. Two of them — the top right and the bottom left —
|
||||||
|
run a short fade out of the palette's hot end and back into the border grey,
|
||||||
|
brightest colour at the corner; everything else, including the other two
|
||||||
|
corners, is that grey.
|
||||||
|
|
||||||
|
The ladder is whatever palette the app is on (``c`` cycles them), which is why
|
||||||
|
every function here takes ``stops`` rather than reading a module constant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import cache
|
||||||
|
|
||||||
|
from rich.cells import cell_len
|
||||||
|
from rich.console import Group, RenderableType
|
||||||
|
from rich.text import Text
|
||||||
|
from textual.widget import Widget
|
||||||
|
|
||||||
|
from ..palettes import DEFAULT_PALETTE, PALETTES, brightness
|
||||||
|
from .common import active_stops
|
||||||
|
|
||||||
|
#: The frame itself: one grey, the same grey as the scrollbar thumb.
|
||||||
|
EDGE = "bright_black"
|
||||||
|
|
||||||
|
#: How many cells a corner fade reaches into the edge.
|
||||||
|
FADE_CELLS = 4
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def fade_ramp(stops: tuple[str, ...]) -> tuple[str, ...]:
|
||||||
|
"""The corner ramp: brightest cell first, border grey past its end.
|
||||||
|
|
||||||
|
The colours come from the palette's hot end, but a ladder is a hue walk,
|
||||||
|
not a brightness walk: ``spectrum`` ends on ``red``, which is dimmer than
|
||||||
|
the ``yellow`` just before it, so painting the ladder backwards put the
|
||||||
|
dim colour at the corner and the glow out on the edge. Sorting the hot
|
||||||
|
end by brightness keeps the corner the brightest cell and dims outwards,
|
||||||
|
whatever theme sits behind the names.
|
||||||
|
"""
|
||||||
|
hot = [name for name in stops if name != EDGE][-FADE_CELLS:]
|
||||||
|
return tuple(sorted(hot, key=brightness, reverse=True))
|
||||||
|
|
||||||
|
|
||||||
|
def corner_fade(distance: int, stops: tuple[str, ...] = ()) -> str:
|
||||||
|
"""Colour ``distance`` cells out from a corner; 0 is the corner glyph.
|
||||||
|
|
||||||
|
The fade is short — four cells — and runs from the brightest colour the
|
||||||
|
palette's hot end offers back into the plain border grey.
|
||||||
|
"""
|
||||||
|
if not stops:
|
||||||
|
stops = PALETTES[DEFAULT_PALETTE]
|
||||||
|
ramp = fade_ramp(tuple(stops))
|
||||||
|
distance = max(0, distance)
|
||||||
|
return ramp[distance] if distance < len(ramp) else EDGE
|
||||||
|
|
||||||
|
|
||||||
|
def inner_size(width: int, height: int) -> tuple[int, int]:
|
||||||
|
"""The content box inside the frame."""
|
||||||
|
return max(1, width - 2), max(1, height - 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _edge_cells(width: int, faded_end: str, stops: tuple[str, ...]) -> list[tuple[str, str]]:
|
||||||
|
"""Inner cells of an edge; only one end of it fades into a corner."""
|
||||||
|
inner = max(0, width - 2)
|
||||||
|
if faded_end == "left":
|
||||||
|
return [("─", corner_fade(column + 1, stops)) for column in range(inner)]
|
||||||
|
return [("─", corner_fade(inner - column, stops)) for column in range(inner)]
|
||||||
|
|
||||||
|
|
||||||
|
def _paint(line: Text, cells: list[tuple[str, str]]) -> None:
|
||||||
|
"""Append cells, grouping neighbours that share a style into one span."""
|
||||||
|
style: str | None = None
|
||||||
|
run: list[str] = []
|
||||||
|
for char, cell_style in cells:
|
||||||
|
if cell_style != style:
|
||||||
|
if run:
|
||||||
|
line.append("".join(run), style=style)
|
||||||
|
run, style = [], cell_style
|
||||||
|
run.append(char)
|
||||||
|
if run:
|
||||||
|
line.append("".join(run), style=style)
|
||||||
|
|
||||||
|
|
||||||
|
def top(width: int, title: str = "", stops: tuple[str, ...] = ()) -> Text:
|
||||||
|
width = max(2, width)
|
||||||
|
cells = _edge_cells(width, "right", stops) # only the top-right corner fades
|
||||||
|
if title:
|
||||||
|
label = f" {title} "
|
||||||
|
start = 1
|
||||||
|
if start + cell_len(label) <= len(cells):
|
||||||
|
for offset, char in enumerate(label):
|
||||||
|
cells[start + offset] = (char, "bold")
|
||||||
|
|
||||||
|
line = Text(no_wrap=True)
|
||||||
|
line.append("╭", style=EDGE)
|
||||||
|
_paint(line, cells)
|
||||||
|
line.append("╮", style=f"bold {corner_fade(0, stops)}")
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
def bottom(width: int, stops: tuple[str, ...] = ()) -> Text:
|
||||||
|
width = max(2, width)
|
||||||
|
line = Text(no_wrap=True)
|
||||||
|
line.append("╰", style=f"bold {corner_fade(0, stops)}")
|
||||||
|
_paint(line, _edge_cells(width, "left", stops)) # only the bottom-left fades
|
||||||
|
line.append("╯", style=EDGE)
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
def side_style(row: int, rows: int, side: str = "left", stops: tuple[str, ...] = ()) -> str:
|
||||||
|
"""Colour of a side cell: only two corners fade, so only one end glows."""
|
||||||
|
if side == "right":
|
||||||
|
return corner_fade(row + 1, stops) # fades down from the top-right corner
|
||||||
|
return corner_fade(rows - row, stops) # fades up from the bottom-left corner
|
||||||
|
|
||||||
|
|
||||||
|
def side_row(
|
||||||
|
row: int, rows: int, side: str = "left", stops: tuple[str, ...] = ()
|
||||||
|
) -> Text:
|
||||||
|
return Text("│", style=side_style(row, rows, side, stops))
|
||||||
|
|
||||||
|
|
||||||
|
def wrap(
|
||||||
|
lines: list[Text],
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
title: str = "",
|
||||||
|
stops: tuple[str, ...] = (),
|
||||||
|
) -> RenderableType:
|
||||||
|
"""Frame ``lines`` (already sized for the content box) in a pane."""
|
||||||
|
width = max(4, width)
|
||||||
|
height = max(2, height)
|
||||||
|
inner_width, inner_height = inner_size(width, height)
|
||||||
|
|
||||||
|
framed: list[Text] = [top(width, title, stops)]
|
||||||
|
for index, line in enumerate(lines[:inner_height]):
|
||||||
|
if cell_len(line.plain) > inner_width:
|
||||||
|
# A pane must never spill over its neighbour: trim, whatever the
|
||||||
|
# caller asked for.
|
||||||
|
line = line.copy()
|
||||||
|
line.truncate(inner_width, overflow="ellipsis")
|
||||||
|
row = Text(no_wrap=True)
|
||||||
|
row.append_text(side_row(index, inner_height, "left", stops))
|
||||||
|
row.append_text(line)
|
||||||
|
pad = inner_width - cell_len(line.plain)
|
||||||
|
if pad > 0:
|
||||||
|
row.append(" " * pad)
|
||||||
|
row.append_text(side_row(index, inner_height, "right", stops))
|
||||||
|
framed.append(row)
|
||||||
|
while len(framed) < height - 1:
|
||||||
|
index = len(framed) - 1
|
||||||
|
row = Text(no_wrap=True)
|
||||||
|
row.append_text(side_row(index, inner_height, "left", stops))
|
||||||
|
row.append(" " * inner_width)
|
||||||
|
row.append_text(side_row(index, inner_height, "right", stops))
|
||||||
|
framed.append(row)
|
||||||
|
framed.append(bottom(width, stops))
|
||||||
|
return Group(*framed[:height])
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# frame pieces, for containers that cannot draw their own content
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
class FrameTop(Widget):
|
||||||
|
can_focus = False
|
||||||
|
|
||||||
|
def __init__(self, title: str = "", *, id: str | None = None) -> None:
|
||||||
|
super().__init__(id=id)
|
||||||
|
self.title = title
|
||||||
|
|
||||||
|
def render(self) -> RenderableType:
|
||||||
|
return top(max(2, self.size.width), self.title, active_stops(self.app))
|
||||||
|
|
||||||
|
|
||||||
|
class FrameBottom(Widget):
|
||||||
|
can_focus = False
|
||||||
|
|
||||||
|
def render(self) -> RenderableType:
|
||||||
|
return bottom(max(2, self.size.width), active_stops(self.app))
|
||||||
|
|
||||||
|
|
||||||
|
class FrameSide(Widget):
|
||||||
|
can_focus = False
|
||||||
|
|
||||||
|
def __init__(self, side: str = "left", *, id: str | None = None) -> None:
|
||||||
|
super().__init__(id=id)
|
||||||
|
self.side = side
|
||||||
|
|
||||||
|
def render(self) -> RenderableType:
|
||||||
|
rows = max(1, self.size.height)
|
||||||
|
stops = active_stops(self.app)
|
||||||
|
return Group(
|
||||||
|
*[side_row(row, rows, self.side, stops) for row in range(rows)]
|
||||||
|
)
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""Header: the catalogue's hero, the clock and a live ticker."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
|
from rich.cells import cell_len
|
||||||
|
from rich.console import Group, RenderableType
|
||||||
|
from rich.text import Text
|
||||||
|
from textual.widget import Widget
|
||||||
|
|
||||||
|
from .common import (
|
||||||
|
active_stops,
|
||||||
|
clock_time,
|
||||||
|
date_label,
|
||||||
|
gradient,
|
||||||
|
marquee,
|
||||||
|
state_glyph,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ..app import BeefyPlayerApp
|
||||||
|
|
||||||
|
|
||||||
|
def _hero_line(
|
||||||
|
markup: str,
|
||||||
|
index: int,
|
||||||
|
width: int,
|
||||||
|
gradient_fill: bool,
|
||||||
|
stops: tuple[str, ...],
|
||||||
|
) -> Text:
|
||||||
|
"""One row of the hero: spectrum-painted glyphs, or the art's own colours."""
|
||||||
|
if not gradient_fill:
|
||||||
|
line = Text.from_markup(markup)
|
||||||
|
line.no_wrap = True
|
||||||
|
return line
|
||||||
|
|
||||||
|
line = Text(no_wrap=True)
|
||||||
|
for column, char in enumerate(markup):
|
||||||
|
position = (column / max(1, width - 1) + index * 0.055) % 1.0
|
||||||
|
line.append(char, style=f"bold {gradient(stops, position)}")
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
class HeaderBar(Widget):
|
||||||
|
can_focus = False
|
||||||
|
|
||||||
|
def render(self) -> RenderableType:
|
||||||
|
app = cast("BeefyPlayerApp", self.app)
|
||||||
|
provider = getattr(app, "provider", None)
|
||||||
|
hero = tuple(getattr(provider, "hero", ()) or ())
|
||||||
|
hero_lines = tuple(getattr(provider, "hero_lines", ()) or ())
|
||||||
|
gradient_fill = bool(getattr(provider, "hero_gradient", False))
|
||||||
|
stops = active_stops(app)
|
||||||
|
|
||||||
|
width = max(24, self.size.width)
|
||||||
|
height = max(1, self.size.height)
|
||||||
|
state = app.playing_state
|
||||||
|
glyph, colour = state_glyph(state)
|
||||||
|
|
||||||
|
# Measure what the hero *shows*: the rows are rich markup, so their
|
||||||
|
# raw length says nothing about the columns they occupy.
|
||||||
|
hero_width = max(
|
||||||
|
(cell_len(Text.from_markup(row).plain) for row in hero), default=0
|
||||||
|
)
|
||||||
|
middle_width = max((cell_len(row) for row in hero_lines), default=0)
|
||||||
|
right = (clock_time(), date_label(), f"vol {app.volume}%")
|
||||||
|
right_width = max(cell_len(item) for item in right)
|
||||||
|
|
||||||
|
show_middle = bool(hero_lines) and width >= hero_width + middle_width + 8
|
||||||
|
show_right = width >= hero_width + right_width + 8
|
||||||
|
if show_middle and show_right:
|
||||||
|
show_middle = (
|
||||||
|
width >= hero_width + 4 + middle_width + 2 + right_width
|
||||||
|
)
|
||||||
|
|
||||||
|
# Centre the short text block against however tall the hero is: a
|
||||||
|
# three-line caption beside a seven-row picture sits in the middle.
|
||||||
|
text_offset = max(0, (len(hero) - len(hero_lines)) // 2)
|
||||||
|
|
||||||
|
lines: list[Text] = []
|
||||||
|
for index, row in enumerate(hero):
|
||||||
|
line = _hero_line(row, index, hero_width, gradient_fill, stops)
|
||||||
|
# Art rows are right-trimmed, so a row ending in a blank cell is a
|
||||||
|
# column short; pad it back or the caption and clock drift left.
|
||||||
|
short = hero_width - cell_len(line.plain)
|
||||||
|
if short > 0:
|
||||||
|
line.append(" " * short)
|
||||||
|
if show_middle and text_offset <= index < text_offset + len(hero_lines):
|
||||||
|
middle = hero_lines[index - text_offset]
|
||||||
|
line.append(" " * 4)
|
||||||
|
line.append(middle, style="dim")
|
||||||
|
# Keep the right block on one column even though the middle
|
||||||
|
# lines differ in length ("TILDERADIO" is shorter than
|
||||||
|
# "STREAMED BY / FOR USERS OF THE").
|
||||||
|
line.append(" " * max(0, middle_width - cell_len(middle)))
|
||||||
|
if show_right and text_offset <= index < text_offset + len(right):
|
||||||
|
used = hero_width
|
||||||
|
if show_middle:
|
||||||
|
used += 4 + middle_width
|
||||||
|
# Right-align the block *and* every item inside it: the clock,
|
||||||
|
# the date and the volume each keep their own length, so pad
|
||||||
|
# the shortest ones instead of left-aligning the whole block.
|
||||||
|
pad = max(1, width - used - right_width)
|
||||||
|
item = right[index - text_offset]
|
||||||
|
line.append(" " * pad)
|
||||||
|
line.append(" " * max(0, right_width - cell_len(item)))
|
||||||
|
style = "bold" if index == text_offset else "dim"
|
||||||
|
line.append(item, style=style)
|
||||||
|
lines.append(line)
|
||||||
|
|
||||||
|
ticker: Text | None = None
|
||||||
|
if height >= 4:
|
||||||
|
ticker = Text(no_wrap=True)
|
||||||
|
prefix = f"{glyph} {state.upper():<8} "
|
||||||
|
ticker.append(prefix, style=f"bold {colour}")
|
||||||
|
station = app.current
|
||||||
|
if app.notice:
|
||||||
|
text = app.notice
|
||||||
|
text_style = "italic yellow"
|
||||||
|
elif station is not None:
|
||||||
|
song = station.now_playing
|
||||||
|
if song is not None:
|
||||||
|
text = (
|
||||||
|
f"{station.title} · "
|
||||||
|
f"{song.display_artist} — {song.display_title}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
text = f"{station.title} · {station.description}"
|
||||||
|
text_style = ""
|
||||||
|
else:
|
||||||
|
text = "Pick a station — press enter to tune in"
|
||||||
|
text_style = "italic dim"
|
||||||
|
ticker.append(
|
||||||
|
marquee(text, max(4, width - cell_len(prefix))), style=text_style
|
||||||
|
)
|
||||||
|
|
||||||
|
# A short terminal loses the bottom of the hero before it loses the
|
||||||
|
# ticker: the ticker carries the state, the art can be cropped.
|
||||||
|
if ticker is not None:
|
||||||
|
if len(lines) >= height:
|
||||||
|
lines = lines[: height - 1]
|
||||||
|
lines.append(ticker)
|
||||||
|
return Group(*lines[:height])
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""Full-screen keyboard help."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
from typing import TYPE_CHECKING, ClassVar, cast
|
||||||
|
|
||||||
|
from rich.console import Group
|
||||||
|
from rich.table import Table
|
||||||
|
from rich.text import Text
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import VerticalScroll
|
||||||
|
from textual.screen import ModalScreen
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
from .. import __version__
|
||||||
|
from ..palettes import PALETTE_ORDER, PALETTES
|
||||||
|
from ..providers import PROVIDERS, available_providers
|
||||||
|
from .common import active_stops, gradient
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ..app import BeefyPlayerApp
|
||||||
|
|
||||||
|
#: Where the project lives (shown at the foot of the help screen).
|
||||||
|
URL = "https://tildeforge.dev/beefcarver/beefy-player"
|
||||||
|
|
||||||
|
HELP_ROWS: tuple[tuple[str, str], ...] = (
|
||||||
|
("↑ / ↓ j / k", "move through the station list"),
|
||||||
|
("pgup / pgdn", "page through the station list"),
|
||||||
|
("enter", "tune in to the selected station"),
|
||||||
|
("space", "play / pause the current station"),
|
||||||
|
("s", "stop playback"),
|
||||||
|
("] / [", "next / previous station (tunes immediately)"),
|
||||||
|
("+ / -", "volume up / down"),
|
||||||
|
("m", "mute / unmute"),
|
||||||
|
("f", "favourite — favourites float to the top"),
|
||||||
|
("/", "filter stations by title, genre or description"),
|
||||||
|
("esc", "close the search box; esc again clears the filter"),
|
||||||
|
(
|
||||||
|
"v",
|
||||||
|
"cycle visualiser: mirror → bars → wave → waterfall → radial → braille",
|
||||||
|
),
|
||||||
|
("c", "cycle the colour palette (listed below)"),
|
||||||
|
("r", "refresh stations, schedule and song history"),
|
||||||
|
("p", "switch station catalogue (SomaFM / tilderadio)"),
|
||||||
|
("g / G", "jump to the top / bottom of the list"),
|
||||||
|
("? ", "this help"),
|
||||||
|
("q", "quit"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class HelpScreen(ModalScreen[None]):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
app = cast("BeefyPlayerApp", self.app)
|
||||||
|
provider = app.provider
|
||||||
|
catalogues = Table.grid(padding=(0, 2), expand=False)
|
||||||
|
catalogues.add_column(style="bold cyan", no_wrap=True, justify="right")
|
||||||
|
catalogues.add_column(style="bold", no_wrap=True)
|
||||||
|
catalogues.add_column(style="dim")
|
||||||
|
for slug in available_providers():
|
||||||
|
catalogue = PROVIDERS[slug]
|
||||||
|
marker = "●" if slug == provider.id else " "
|
||||||
|
pane = "schedule pane" if catalogue.has_schedule else "station list"
|
||||||
|
catalogues.add_row(
|
||||||
|
marker, slug, f"{catalogue.name} — {pane}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- palettes, two per row so sixteen still fit ------------------ #
|
||||||
|
names = list(PALETTE_ORDER)
|
||||||
|
name_width = max(len(name) for name in names)
|
||||||
|
|
||||||
|
def swatch(name: str) -> Text:
|
||||||
|
stops = PALETTES[name]
|
||||||
|
block = Text(no_wrap=True)
|
||||||
|
for step in range(8):
|
||||||
|
block.append("█", style=gradient(stops, step / 7))
|
||||||
|
return block
|
||||||
|
|
||||||
|
def entry(name: str, first: bool) -> Text:
|
||||||
|
line = Text(no_wrap=True)
|
||||||
|
if not first:
|
||||||
|
line.append(" ")
|
||||||
|
line.append("● " if name == app.palette else " ", style="bold cyan")
|
||||||
|
line.append(f"{name:<{name_width}}", style="bold")
|
||||||
|
line.append(" ")
|
||||||
|
line.append_text(swatch(name))
|
||||||
|
return line
|
||||||
|
|
||||||
|
palette_rows: list[Text] = []
|
||||||
|
for index in range(0, len(names), 2):
|
||||||
|
row = entry(names[index], True)
|
||||||
|
if index + 1 < len(names):
|
||||||
|
row.append_text(entry(names[index + 1], False))
|
||||||
|
palette_rows.append(row)
|
||||||
|
|
||||||
|
palette_title = Text()
|
||||||
|
palette_title.append("PALETTES", style="bold cyan")
|
||||||
|
palette_title.append(
|
||||||
|
" c cycles them · remembered between runs", style="dim"
|
||||||
|
)
|
||||||
|
|
||||||
|
catalogue_title = Text()
|
||||||
|
catalogue_title.append("CATALOGUES", style="bold cyan")
|
||||||
|
catalogue_title.append(
|
||||||
|
" switch with p or --provider at start", style="dim"
|
||||||
|
)
|
||||||
|
|
||||||
|
table = Table.grid(padding=(0, 2), expand=False)
|
||||||
|
table.add_column(style="bold cyan", no_wrap=True, justify="right")
|
||||||
|
table.add_column(style="")
|
||||||
|
for key, description in HELP_ROWS:
|
||||||
|
table.add_row(key, description)
|
||||||
|
|
||||||
|
header = Text()
|
||||||
|
header.append("◤ ", style=f"bold {gradient(active_stops(self.app), 0.9)}")
|
||||||
|
header.append("BEEFY PLAYER", style=f"bold {gradient(active_stops(self.app), 0.55)}")
|
||||||
|
header.append(" keyboard shortcuts", style="dim")
|
||||||
|
header.append(" · ↑ ↓ / pgup pgdn scroll", style="dim")
|
||||||
|
|
||||||
|
# One attribution line: version and where the project lives.
|
||||||
|
footer = Text(no_wrap=True)
|
||||||
|
footer.append(f"beefy player {__version__}", style="bold")
|
||||||
|
footer.append(" · ")
|
||||||
|
footer.append(
|
||||||
|
URL, style=f"dim link {URL}"
|
||||||
|
)
|
||||||
|
|
||||||
|
body = Group(
|
||||||
|
header,
|
||||||
|
Text(""),
|
||||||
|
table,
|
||||||
|
Text(""),
|
||||||
|
catalogue_title,
|
||||||
|
catalogues,
|
||||||
|
Text(""),
|
||||||
|
palette_title,
|
||||||
|
*palette_rows,
|
||||||
|
Text(""),
|
||||||
|
footer,
|
||||||
|
)
|
||||||
|
# The body outgrows a small terminal; the scroll container keeps it
|
||||||
|
# readable (and gives the box its border).
|
||||||
|
with VerticalScroll(id="help"):
|
||||||
|
yield Static(body, id="help-body")
|
||||||
|
|
||||||
|
#: Keys that scroll the page instead of closing it (help outgrows a 24-row
|
||||||
|
#: terminal, so it has to be readable there).
|
||||||
|
SCROLL_KEYS: ClassVar[dict[str, str]] = {
|
||||||
|
"up": "scroll_up",
|
||||||
|
"k": "scroll_up",
|
||||||
|
"down": "scroll_down",
|
||||||
|
"j": "scroll_down",
|
||||||
|
"pageup": "scroll_page_up",
|
||||||
|
"pagedown": "scroll_page_down",
|
||||||
|
"home": "scroll_home",
|
||||||
|
"g": "scroll_home",
|
||||||
|
"end": "scroll_end",
|
||||||
|
"shift+g": "scroll_end",
|
||||||
|
}
|
||||||
|
|
||||||
|
def on_key(self, event) -> None:
|
||||||
|
event.stop()
|
||||||
|
method = self.SCROLL_KEYS.get(event.key)
|
||||||
|
if method is not None:
|
||||||
|
body = self.query_one("#help")
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
getattr(body, method)(animate=False)
|
||||||
|
return
|
||||||
|
self.dismiss(None)
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Catalogue picker: switch between SomaFM, tilderadio and whatever comes next."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from rich.console import Group
|
||||||
|
from rich.text import Text
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import VerticalScroll
|
||||||
|
from textual.screen import ModalScreen
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
from ..providers import PROVIDERS, available_providers
|
||||||
|
from .common import active_stops, gradient, truncate
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderScreen(ModalScreen[str | None]):
|
||||||
|
"""A list of catalogues; enter selects, esc leaves everything as it was."""
|
||||||
|
|
||||||
|
def __init__(self, current_id: str = "") -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.slugs: tuple[str, ...] = available_providers()
|
||||||
|
self.current_id = current_id
|
||||||
|
self.index = self.slugs.index(current_id) if current_id in self.slugs else 0
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
# A scroll container keeps thirteen catalogues navigable on a short
|
||||||
|
# terminal; the body is given its exact line count so it measures.
|
||||||
|
with VerticalScroll(id="providers"):
|
||||||
|
yield Static(self._body(), id="providers-body")
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
body = self.query_one("#providers-body", Static)
|
||||||
|
body.styles.height = len(self.slugs) + 5 # heading, blank, rows, footer
|
||||||
|
|
||||||
|
def _body(self) -> Group:
|
||||||
|
header = Text()
|
||||||
|
header.append("◤ ", style=f"bold {gradient(active_stops(self.app), 0.9)}")
|
||||||
|
header.append("CATALOGUES", style=f"bold {gradient(active_stops(self.app), 0.55)}")
|
||||||
|
header.append(" · pick a station catalogue", style="dim")
|
||||||
|
|
||||||
|
name_width = (
|
||||||
|
max(
|
||||||
|
(
|
||||||
|
len(PROVIDERS[slug].name)
|
||||||
|
for slug in self.slugs
|
||||||
|
if slug in PROVIDERS
|
||||||
|
),
|
||||||
|
default=12,
|
||||||
|
)
|
||||||
|
+ 2
|
||||||
|
)
|
||||||
|
|
||||||
|
rows: list[Text] = []
|
||||||
|
for index, slug in enumerate(self.slugs):
|
||||||
|
provider = PROVIDERS[slug]
|
||||||
|
selected = index == self.index
|
||||||
|
row = Text(no_wrap=True)
|
||||||
|
row.append(
|
||||||
|
"▶ " if selected else " ", style="bold magenta" if selected else ""
|
||||||
|
)
|
||||||
|
row.append(f"{index + 1:>2} ", style="dim")
|
||||||
|
row.append(
|
||||||
|
f"{truncate(provider.name, name_width - 2):<{name_width}}",
|
||||||
|
style="bold underline" if selected else "bold",
|
||||||
|
)
|
||||||
|
row.append(" ")
|
||||||
|
# marker(2) + number(5) + name + gap(2) + tagline + current dot(3)
|
||||||
|
room = max(8, 58 - 2 - 5 - name_width - 2 - 3)
|
||||||
|
row.append(truncate(provider.tagline, room), style="italic dim")
|
||||||
|
if slug == self.current_id:
|
||||||
|
row.append(" ●", style="bold green")
|
||||||
|
rows.append(row)
|
||||||
|
|
||||||
|
current = PROVIDERS.get(self.current_id)
|
||||||
|
footer = Text()
|
||||||
|
footer.append("\n")
|
||||||
|
if current is not None:
|
||||||
|
line = truncate(f"now: {current.name} — {current.station_source}", 58)
|
||||||
|
footer.append(line + "\n", style="dim")
|
||||||
|
footer.append(
|
||||||
|
"↑ ↓ choose · enter switch · esc cancel",
|
||||||
|
style="dim",
|
||||||
|
)
|
||||||
|
return Group(header, Text(""), *rows, footer)
|
||||||
|
|
||||||
|
# -- interaction ----------------------------------------------------- #
|
||||||
|
|
||||||
|
def _move(self, delta: int) -> None:
|
||||||
|
self.index = (self.index + delta) % len(self.slugs)
|
||||||
|
body = self.query_one("#providers-body", Static)
|
||||||
|
body.update(self._body())
|
||||||
|
|
||||||
|
box = self.query_one("#providers", VerticalScroll)
|
||||||
|
|
||||||
|
def follow() -> None:
|
||||||
|
visible = max(1, box.size.height)
|
||||||
|
target = max(0, 2 + self.index - (visible - 3) // 2)
|
||||||
|
box.scroll_to(y=target, animate=False)
|
||||||
|
|
||||||
|
box.call_after_refresh(follow)
|
||||||
|
|
||||||
|
def on_key(self, event) -> None:
|
||||||
|
key = event.key
|
||||||
|
if key in ("up", "k", "shift+tab"):
|
||||||
|
self._move(-1)
|
||||||
|
elif key in ("down", "j", "tab"):
|
||||||
|
self._move(1)
|
||||||
|
elif key in ("enter", "space"):
|
||||||
|
self.dismiss(self.slugs[self.index])
|
||||||
|
elif key == "escape" or key == "q":
|
||||||
|
self.dismiss(None)
|
||||||
|
elif key.isdigit() and key != "0":
|
||||||
|
choice = int(key) - 1
|
||||||
|
if 0 <= choice < len(self.slugs):
|
||||||
|
self.dismiss(self.slugs[choice])
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
event.stop()
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""The left pane for single-stream catalogues: what is on, what is next.
|
||||||
|
|
||||||
|
A catalogue with one station does not need a station list, but it usually has
|
||||||
|
a broadcast schedule. This pane answers the two questions a listener actually
|
||||||
|
has — "who is on air?" and "when is the next show?" — in the space the list
|
||||||
|
would have used.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
|
from rich.console import RenderableType
|
||||||
|
from rich.text import Text
|
||||||
|
from textual.widget import Widget
|
||||||
|
|
||||||
|
from . import frame
|
||||||
|
from .common import active_stops, local_time, local_zone, state_glyph, truncate
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ..app import BeefyPlayerApp
|
||||||
|
from ..models import Broadcast
|
||||||
|
|
||||||
|
|
||||||
|
def _window(broadcast: Broadcast | None) -> str:
|
||||||
|
"""``since 22:00 · until 23:00`` — whichever ends the feed actually sent."""
|
||||||
|
if broadcast is None:
|
||||||
|
return ""
|
||||||
|
start = local_time(broadcast.start) if broadcast.start else ""
|
||||||
|
end = local_time(broadcast.end) if broadcast.end else ""
|
||||||
|
if start and end:
|
||||||
|
return f"since {start} · until {end}"
|
||||||
|
if start:
|
||||||
|
return f"since {start}"
|
||||||
|
if end:
|
||||||
|
return f"until {end}"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _day_label(day: date, today: date) -> str:
|
||||||
|
if day == today:
|
||||||
|
return "TODAY"
|
||||||
|
if day == today + timedelta(days=1):
|
||||||
|
return "TOMORROW"
|
||||||
|
return day.strftime("%a %d %b").upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _upcoming_rows(
|
||||||
|
upcoming: list[Broadcast], width: int, budget: int
|
||||||
|
) -> list[Text]:
|
||||||
|
"""Upcoming shows, grouped by local day, clipped to the pane."""
|
||||||
|
zone = local_zone()
|
||||||
|
today = datetime.now(zone).date()
|
||||||
|
|
||||||
|
# (is a show, its line) so the "… +N more" count talks about shows and
|
||||||
|
# not about the day headings in between.
|
||||||
|
rows: list[tuple[bool, Text]] = []
|
||||||
|
current_day: date | None = None
|
||||||
|
for item in upcoming:
|
||||||
|
if item.start is None:
|
||||||
|
continue
|
||||||
|
day = item.start.astimezone(zone).date()
|
||||||
|
if day != current_day:
|
||||||
|
current_day = day
|
||||||
|
rows.append((False, Text(_day_label(day, today), style="dim")))
|
||||||
|
row = Text(no_wrap=True)
|
||||||
|
row.append(" ")
|
||||||
|
row.append(local_time(item.start), style="bold")
|
||||||
|
row.append(" ")
|
||||||
|
row.append(truncate(item.display_name, max(4, width - 9)))
|
||||||
|
rows.append((True, row))
|
||||||
|
|
||||||
|
if len(rows) > budget:
|
||||||
|
kept = rows[: max(0, budget - 1)]
|
||||||
|
while kept and not kept[-1][0]:
|
||||||
|
kept.pop()
|
||||||
|
dropped = sum(1 for is_show, _ in rows[len(kept) :] if is_show)
|
||||||
|
lines = [text for _, text in kept]
|
||||||
|
if dropped:
|
||||||
|
lines.append(Text(f"… +{dropped} more", style="dim italic"))
|
||||||
|
return lines
|
||||||
|
return [text for _, text in rows]
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulePanel(Widget):
|
||||||
|
can_focus = False
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.border_title = "SCHEDULE"
|
||||||
|
|
||||||
|
def render(self) -> RenderableType:
|
||||||
|
app = cast("BeefyPlayerApp", self.app)
|
||||||
|
width = max(12, self.size.width)
|
||||||
|
height = max(3, self.size.height)
|
||||||
|
stops = active_stops(app)
|
||||||
|
width, height = frame.inner_size(width, height)
|
||||||
|
|
||||||
|
broadcasts = list(getattr(app, "schedule", ()) or ())
|
||||||
|
loaded = bool(getattr(app, "schedule_loaded", False))
|
||||||
|
live = next((item for item in broadcasts if item.live), None)
|
||||||
|
upcoming = [item for item in broadcasts if not item.live]
|
||||||
|
|
||||||
|
lines: list[Text] = []
|
||||||
|
|
||||||
|
head = Text(no_wrap=True)
|
||||||
|
glyph, colour = state_glyph("playing" if live else "stopped")
|
||||||
|
head.append(f"{glyph} ", style=f"bold {colour}")
|
||||||
|
head.append("ON AIR" if live else "AUTO DJ", style="bold magenta")
|
||||||
|
if live is not None:
|
||||||
|
head.append(" ", style="")
|
||||||
|
head.append(
|
||||||
|
truncate(live.display_name, max(4, width - 12)),
|
||||||
|
style="bold",
|
||||||
|
)
|
||||||
|
lines.append(head)
|
||||||
|
|
||||||
|
window = _window(live)
|
||||||
|
lines.append(
|
||||||
|
Text(truncate(window, width), style="dim")
|
||||||
|
if window
|
||||||
|
else Text("library rotation", style="dim italic")
|
||||||
|
)
|
||||||
|
lines.append(Text(""))
|
||||||
|
lines.append(Text("UPCOMING", style="bold cyan"))
|
||||||
|
|
||||||
|
if not loaded:
|
||||||
|
lines.append(Text("loading schedule…", style="dim italic"))
|
||||||
|
elif not upcoming:
|
||||||
|
lines.append(Text("nothing scheduled", style="dim italic"))
|
||||||
|
else:
|
||||||
|
lines.extend(
|
||||||
|
_upcoming_rows(upcoming, width, max(0, height - 1 - len(lines)))
|
||||||
|
)
|
||||||
|
|
||||||
|
lines.append(Text(""))
|
||||||
|
while len(lines) < height:
|
||||||
|
lines.append(Text(""))
|
||||||
|
return frame.wrap(
|
||||||
|
lines[:height],
|
||||||
|
max(12, self.size.width),
|
||||||
|
max(3, self.size.height),
|
||||||
|
self.border_title,
|
||||||
|
stops,
|
||||||
|
)
|
||||||
File renamed without changes.
@@ -0,0 +1,416 @@
|
|||||||
|
"""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 RenderableType
|
||||||
|
from rich.text import Text
|
||||||
|
from textual.widget import Widget
|
||||||
|
|
||||||
|
from ..palettes import palette_stops
|
||||||
|
from ..spectrum import Frame, SpectrumEngine
|
||||||
|
from . import frame
|
||||||
|
from .common import LEVELS, active_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"
|
||||||
|
#: The active colour ladder; ``render()`` refreshes it each frame.
|
||||||
|
self._stops: tuple[str, ...] = palette_stops(None)
|
||||||
|
self._palettes: dict[tuple, 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)
|
||||||
|
snapshot = self.engine.snapshot()
|
||||||
|
self._stops = active_stops(app)
|
||||||
|
outer_width, outer_height = max(12, self.size.width), max(4, self.size.height)
|
||||||
|
width, height = frame.inner_size(outer_width, outer_height)
|
||||||
|
mode = getattr(app, "visualizer", "both")
|
||||||
|
phase = int(getattr(app, "tick_count", 0) // 2)
|
||||||
|
dim = snapshot.status == "idle"
|
||||||
|
self._record(snapshot, int(getattr(app, "tick_count", 0)))
|
||||||
|
|
||||||
|
lines: list[Text]
|
||||||
|
if mode == "wave":
|
||||||
|
lines = self._wave(snapshot, width, height, phase, dim)
|
||||||
|
elif mode == "braille":
|
||||||
|
lines = self._braille(snapshot, width, height, phase, dim)
|
||||||
|
elif mode == "waterfall":
|
||||||
|
lines = self._waterfall(width, height, dim)
|
||||||
|
elif mode == "radial":
|
||||||
|
lines = self._radial(snapshot, width, height, phase, dim)
|
||||||
|
elif mode == "bars" or height < 8:
|
||||||
|
lines = self._bars(snapshot, 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(snapshot, width, top_height, phase, dim)
|
||||||
|
mirrored = self._bars(
|
||||||
|
snapshot, width, bottom_height, phase + 5, True, peaks=False
|
||||||
|
)
|
||||||
|
mirrored.reverse()
|
||||||
|
lines.extend(mirrored)
|
||||||
|
return frame.wrap(
|
||||||
|
lines, outer_width, outer_height, self.border_title, self._stops
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 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]:
|
||||||
|
stops = self._stops
|
||||||
|
key = (height, phase % 12, dim, stops)
|
||||||
|
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(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(self._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(
|
||||||
|
self._stops, edge
|
||||||
|
)
|
||||||
|
elif depth < 0.15:
|
||||||
|
grid[row][column] = "▓"
|
||||||
|
styles[row][column] = "dim " + gradient(self._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
|
||||||
@@ -7,11 +7,11 @@ from typing import TYPE_CHECKING, cast
|
|||||||
|
|
||||||
from rich.console import Group, RenderableType
|
from rich.console import Group, RenderableType
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from textual.containers import Horizontal
|
from textual.containers import Horizontal, Vertical
|
||||||
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,
|
||||||
@@ -21,9 +21,10 @@ from .common import (
|
|||||||
time_ago,
|
time_ago,
|
||||||
truncate,
|
truncate,
|
||||||
)
|
)
|
||||||
|
from .frame import FrameBottom, FrameSide, FrameTop
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..app import SomaFMApp
|
from ..app import BeefyPlayerApp
|
||||||
|
|
||||||
|
|
||||||
class StagePanel(Widget):
|
class StagePanel(Widget):
|
||||||
@@ -36,10 +37,18 @@ class StagePanel(Widget):
|
|||||||
self.border_title = "ON AIR"
|
self.border_title = "ON AIR"
|
||||||
|
|
||||||
def compose(self):
|
def compose(self):
|
||||||
with Horizontal(id="stage_top"):
|
# A container cannot paint its own content, so the frame is made of
|
||||||
yield ArtPane()
|
# thin widgets: top, bottom, and a column either side of the body.
|
||||||
yield StageInfo()
|
yield FrameTop("ON AIR", id="stage_frame_top")
|
||||||
yield HistoryPane()
|
with Horizontal(id="stage_middle"):
|
||||||
|
yield FrameSide("left", id="stage_frame_left")
|
||||||
|
with Vertical(id="stage_body"):
|
||||||
|
with Horizontal(id="stage_top"):
|
||||||
|
yield ArtPane()
|
||||||
|
yield StageInfo()
|
||||||
|
yield HistoryPane()
|
||||||
|
yield FrameSide("right", id="stage_frame_right")
|
||||||
|
yield FrameBottom(id="stage_frame_bottom")
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self.refresh_art()
|
self.refresh_art()
|
||||||
@@ -75,27 +84,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 +116,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,
|
||||||
@@ -137,14 +149,19 @@ class StageInfo(Widget):
|
|||||||
album = Text(no_wrap=True)
|
album = Text(no_wrap=True)
|
||||||
if song.album:
|
if song.album:
|
||||||
album.append(truncate(song.album, max(8, width - 22)), style="dim")
|
album.append(truncate(song.album, max(8, width - 22)), style="dim")
|
||||||
if station.dj:
|
presenter = station.presenter_label
|
||||||
|
if presenter:
|
||||||
if song.album:
|
if song.album:
|
||||||
album.append(" · ", style="dim")
|
album.append(" · ", style="dim")
|
||||||
album.append(f"dj {truncate(station.dj, 18)}", style="dim")
|
album.append(truncate(presenter, max(8, width - 22)), style="dim")
|
||||||
if song.art_url:
|
# The marker means "there is artwork", not "here is a lone box":
|
||||||
|
# without album or presenter there is nothing for it to mark.
|
||||||
|
if song.art_url and album.plain.strip():
|
||||||
album.append(" ▣", style="cyan")
|
album.append(" ▣", style="cyan")
|
||||||
else:
|
else:
|
||||||
artist = Text("waiting for track data…", style="italic yellow")
|
# No track metadata from this catalogue: keep the line, leave it
|
||||||
|
# blank rather than filling it with an apology.
|
||||||
|
artist = Text("")
|
||||||
fallback = station.last_playing or "tuning in"
|
fallback = station.last_playing or "tuning in"
|
||||||
title = Text(marquee(fallback, width), style="bold")
|
title = Text(marquee(fallback, width), style="bold")
|
||||||
album = Text(truncate(station.genre_label, width), style="dim")
|
album = Text(truncate(station.genre_label, width), style="dim")
|
||||||
@@ -188,7 +205,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
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"""Station list with favourites, live markers, a filter and a scrollbar."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
|
from rich.cells import cell_len
|
||||||
|
from rich.console import RenderableType
|
||||||
|
from rich.text import Text
|
||||||
|
from textual.widget import Widget
|
||||||
|
|
||||||
|
from . import frame
|
||||||
|
from .common import active_stops, compact_count, truncate
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ..app import BeefyPlayerApp
|
||||||
|
|
||||||
|
#: Columns the scrollbar occupies (a gap plus the bar itself).
|
||||||
|
GUTTER = 2
|
||||||
|
|
||||||
|
|
||||||
|
class StationList(Widget):
|
||||||
|
can_focus = False
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.border_title = "STATIONS"
|
||||||
|
|
||||||
|
# -- title: filter + position --------------------------------------- #
|
||||||
|
|
||||||
|
def set_filter(self, query: str) -> None:
|
||||||
|
self._sync_title(query)
|
||||||
|
|
||||||
|
def _sync_title(self, query: str | None = None) -> None:
|
||||||
|
"""``STATIONS · /jazz · 3/12`` — the filter and where you are in it."""
|
||||||
|
if query is None:
|
||||||
|
query = ""
|
||||||
|
total = 0
|
||||||
|
index = 0
|
||||||
|
if self.is_mounted:
|
||||||
|
app = cast("BeefyPlayerApp", self.app)
|
||||||
|
query = app.query
|
||||||
|
total = len(app.view)
|
||||||
|
index = app.index
|
||||||
|
|
||||||
|
safe_query = query.replace("[", "(").replace("]", ")") if query else ""
|
||||||
|
parts = ["STATIONS"]
|
||||||
|
if safe_query:
|
||||||
|
parts.append(f"/{safe_query}")
|
||||||
|
if total > 1:
|
||||||
|
parts.append(f"{min(index + 1, total)}/{total}")
|
||||||
|
|
||||||
|
title = " · ".join(parts)
|
||||||
|
if self.border_title != title:
|
||||||
|
self.border_title = title
|
||||||
|
|
||||||
|
def refresh(self, *args, **kwargs): # type: ignore[override]
|
||||||
|
# Every cursor move ends in a refresh, so this is the one place that
|
||||||
|
# has to keep the title's position readout honest.
|
||||||
|
if self.is_mounted:
|
||||||
|
self._sync_title()
|
||||||
|
return super().refresh(*args, **kwargs)
|
||||||
|
|
||||||
|
def on_mouse_scroll_down(self, event) -> None:
|
||||||
|
cast("BeefyPlayerApp", self.app).action_cursor_down()
|
||||||
|
|
||||||
|
def on_mouse_scroll_up(self, event) -> None:
|
||||||
|
cast("BeefyPlayerApp", self.app).action_cursor_up()
|
||||||
|
|
||||||
|
# -- scrollbar geometry --------------------------------------------- #
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def thumb(height: int, total: int, first: int) -> set[int]:
|
||||||
|
"""Rows of the pane the scrollbar thumb covers."""
|
||||||
|
if total <= height:
|
||||||
|
return set(range(height))
|
||||||
|
size = max(1, round(height * height / total))
|
||||||
|
span = max(0, total - height)
|
||||||
|
top = 0 if span == 0 else round(first * (height - size) / span)
|
||||||
|
return set(range(top, min(height, top + size)))
|
||||||
|
|
||||||
|
def render(self) -> RenderableType:
|
||||||
|
app = cast("BeefyPlayerApp", self.app)
|
||||||
|
width = max(12, self.size.width)
|
||||||
|
height = max(3, self.size.height)
|
||||||
|
stops = active_stops(app)
|
||||||
|
inner_width, inner_height = frame.inner_size(width, height)
|
||||||
|
view = app.view
|
||||||
|
|
||||||
|
if not view:
|
||||||
|
if getattr(app, "search_pending", False):
|
||||||
|
message = "searching the directory…"
|
||||||
|
else:
|
||||||
|
message = "no stations match" if app.query else "loading stations…"
|
||||||
|
return frame.wrap(
|
||||||
|
[Text(truncate(message, inner_width), style="dim italic")],
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
self.border_title,
|
||||||
|
stops,
|
||||||
|
)
|
||||||
|
|
||||||
|
total = len(view)
|
||||||
|
first = 0
|
||||||
|
if total > inner_height:
|
||||||
|
first = max(0, min(app.index - inner_height // 2, total - inner_height))
|
||||||
|
|
||||||
|
scrollable = total > inner_height
|
||||||
|
gutter = GUTTER if scrollable else 0
|
||||||
|
content = max(4, inner_width - gutter)
|
||||||
|
thumb = (
|
||||||
|
self.thumb(inner_height, total, first) if scrollable else set()
|
||||||
|
)
|
||||||
|
|
||||||
|
lines: list[Text] = []
|
||||||
|
for offset in range(min(inner_height, total)):
|
||||||
|
index = first + offset
|
||||||
|
station = view[index]
|
||||||
|
selected = index == app.index
|
||||||
|
current = app.current is not None and app.current.id == station.id
|
||||||
|
playing = current and app.playing_state not in ("stopped", "error")
|
||||||
|
|
||||||
|
marker = "▌" if selected else " "
|
||||||
|
star = "★" if station.favorite else " "
|
||||||
|
play = (
|
||||||
|
("‖" if app.playing_state == "paused" else "▶") if current else " "
|
||||||
|
)
|
||||||
|
|
||||||
|
count_text = ""
|
||||||
|
count_width = 0
|
||||||
|
if content >= 26 and station.listeners > 0:
|
||||||
|
count_text = f"{compact_count(station.listeners)} ♪"
|
||||||
|
count_width = cell_len(count_text) + 1
|
||||||
|
|
||||||
|
prefix = marker + star + play + " "
|
||||||
|
available = content - cell_len(prefix) - count_width
|
||||||
|
title = truncate(station.title, max(4, available))
|
||||||
|
gap = max(0, content - cell_len(prefix) - cell_len(title) - count_width)
|
||||||
|
|
||||||
|
if selected:
|
||||||
|
marker_style = "magenta"
|
||||||
|
title_style = "bold underline"
|
||||||
|
count_style = "bold"
|
||||||
|
elif current:
|
||||||
|
marker_style = "cyan"
|
||||||
|
title_style = "cyan"
|
||||||
|
count_style = "dim"
|
||||||
|
else:
|
||||||
|
marker_style = ""
|
||||||
|
title_style = ""
|
||||||
|
count_style = "dim"
|
||||||
|
|
||||||
|
row = Text(no_wrap=True)
|
||||||
|
row.append(marker, style=marker_style)
|
||||||
|
row.append(star, style="bold yellow" if station.favorite else "")
|
||||||
|
row.append(play, style="cyan" if playing else "")
|
||||||
|
row.append(" ")
|
||||||
|
row.append(title, style=title_style)
|
||||||
|
row.append(" " * gap)
|
||||||
|
if count_text:
|
||||||
|
row.append(count_text, style=count_style)
|
||||||
|
used = cell_len(prefix) + cell_len(title) + gap + cell_len(count_text)
|
||||||
|
if used < content:
|
||||||
|
row.append(" " * (content - used))
|
||||||
|
|
||||||
|
if scrollable:
|
||||||
|
# Just the thumb, in the frame's own grey: no rail.
|
||||||
|
row.append(" ")
|
||||||
|
row.append(
|
||||||
|
"▐" if offset in thumb else " ",
|
||||||
|
style=frame.EDGE,
|
||||||
|
)
|
||||||
|
lines.append(row)
|
||||||
|
|
||||||
|
return frame.wrap(lines, width, height, self.border_title, stops)
|
||||||
@@ -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()
|
||||||
Generated
+27
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1793726008,
|
||||||
|
"narHash": "sha256-jQp1wKvbwWdLBASTXkSi/VYmDJ5AbBi1WvcMt9HWJC8=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "1d060086d60cb76a40cf37109f979d88a5bd6396",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
{
|
||||||
|
description = "beefy-player — a flashy terminal player for internet radio";
|
||||||
|
|
||||||
|
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{ self, nixpkgs }:
|
||||||
|
let
|
||||||
|
systems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
|
"x86_64-darwin"
|
||||||
|
"aarch64-darwin"
|
||||||
|
];
|
||||||
|
|
||||||
|
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||||
|
|
||||||
|
# Things the app shells out to at runtime: mpv for audio, ffmpeg for the
|
||||||
|
# spectrum capture, and parec/pactl from PulseAudio for the monitor source
|
||||||
|
# (PipeWire speaks the same protocol).
|
||||||
|
runtimeDeps =
|
||||||
|
pkgs:
|
||||||
|
with pkgs;
|
||||||
|
[
|
||||||
|
mpv
|
||||||
|
ffmpeg
|
||||||
|
]
|
||||||
|
++ lib.optionals stdenv.hostPlatform.isLinux [ pulseaudio ];
|
||||||
|
|
||||||
|
beefy-player =
|
||||||
|
pkgs:
|
||||||
|
let
|
||||||
|
py = pkgs.python3Packages;
|
||||||
|
in
|
||||||
|
py.buildPythonApplication {
|
||||||
|
pname = "beefy-player";
|
||||||
|
version = "1.2.2";
|
||||||
|
pyproject = true;
|
||||||
|
src = self;
|
||||||
|
|
||||||
|
build-system = [ py.hatchling ];
|
||||||
|
|
||||||
|
dependencies =
|
||||||
|
with py;
|
||||||
|
[
|
||||||
|
textual
|
||||||
|
rich
|
||||||
|
httpx
|
||||||
|
numpy
|
||||||
|
pillow
|
||||||
|
]
|
||||||
|
++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [ dbus-next ];
|
||||||
|
|
||||||
|
nativeBuildInputs = [ pkgs.makeWrapper ];
|
||||||
|
nativeCheckInputs = with py; [
|
||||||
|
pytestCheckHook
|
||||||
|
pytest-asyncio
|
||||||
|
];
|
||||||
|
|
||||||
|
# The suite is offline (catalogues are cached fixtures).
|
||||||
|
doCheck = true;
|
||||||
|
|
||||||
|
pythonImportsCheck = [ "beefy_player" ];
|
||||||
|
|
||||||
|
makeWrapperArgs = [
|
||||||
|
"--prefix"
|
||||||
|
"PATH"
|
||||||
|
":"
|
||||||
|
(pkgs.lib.makeBinPath (runtimeDeps pkgs))
|
||||||
|
];
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "Flashy terminal player for internet radio, with a live spectrum analyser";
|
||||||
|
homepage = "https://tildeforge.dev/beefcarver/beefy-player";
|
||||||
|
license = pkgs.lib.licenses.wtfpl;
|
||||||
|
mainProgram = "beefy-player";
|
||||||
|
platforms = pkgs.lib.platforms.unix;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
# One module body, two install targets: a NixOS system adds the package
|
||||||
|
# to environment.systemPackages, Home Manager to home.packages.
|
||||||
|
mkModule =
|
||||||
|
install:
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
cfg = config.programs.beefy-player;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.programs.beefy-player = {
|
||||||
|
enable = lib.mkEnableOption "beefy-player, a terminal radio player";
|
||||||
|
|
||||||
|
package = lib.mkOption {
|
||||||
|
type = lib.types.package;
|
||||||
|
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||||
|
defaultText = lib.literalMD "the `beefy-player` package from this flake";
|
||||||
|
description = "The beefy-player package to use.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable (lib.setAttrByPath install [ cfg.package ]);
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages = forAllSystems (
|
||||||
|
pkgs:
|
||||||
|
let
|
||||||
|
pkg = beefy-player pkgs;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
default = pkg;
|
||||||
|
beefy-player = pkg;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
apps = forAllSystems (pkgs: {
|
||||||
|
default = {
|
||||||
|
type = "app";
|
||||||
|
program = "${beefy-player pkgs}/bin/beefy-player";
|
||||||
|
meta.description = "Flashy terminal player for internet radio";
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
checks = forAllSystems (pkgs: {
|
||||||
|
beefy-player = beefy-player pkgs;
|
||||||
|
});
|
||||||
|
|
||||||
|
devShells = forAllSystems (
|
||||||
|
pkgs:
|
||||||
|
let
|
||||||
|
inherit (pkgs) lib;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
# No FHS needed here: `nix develop` puts libstdc++ on LD_LIBRARY_PATH
|
||||||
|
# so uv's manylinux wheels load, unlike the plain `nix-shell` case in
|
||||||
|
# shell.nix.
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
packages = runtimeDeps pkgs ++ [
|
||||||
|
pkgs.python3
|
||||||
|
pkgs.uv
|
||||||
|
pkgs.ruff
|
||||||
|
pkgs.git
|
||||||
|
pkgs.cacert
|
||||||
|
];
|
||||||
|
|
||||||
|
env = {
|
||||||
|
UV_PYTHON = "${pkgs.python3}/bin/python3";
|
||||||
|
UV_PYTHON_DOWNLOADS = "never";
|
||||||
|
UV_PROJECT_ENVIRONMENT = "${toString ./.}/.venv";
|
||||||
|
UV_LINK_MODE = "copy";
|
||||||
|
SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
|
||||||
|
};
|
||||||
|
|
||||||
|
LD_LIBRARY_PATH = lib.makeLibraryPath [
|
||||||
|
pkgs.stdenv.cc.cc.lib
|
||||||
|
pkgs.zlib
|
||||||
|
];
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
echo "beefy-player dev shell — uv sync, uv run pytest, uv run beefy-player"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
nixosModules.default = mkModule [
|
||||||
|
"environment"
|
||||||
|
"systemPackages"
|
||||||
|
];
|
||||||
|
nixosModules.beefy-player = self.nixosModules.default;
|
||||||
|
|
||||||
|
homeModules.default = mkModule [
|
||||||
|
"home"
|
||||||
|
"packages"
|
||||||
|
];
|
||||||
|
homeModules.beefy-player = self.homeModules.default;
|
||||||
|
|
||||||
|
formatter = forAllSystems (pkgs: pkgs.nixfmt);
|
||||||
|
|
||||||
|
overlays.default = final: _prev: {
|
||||||
|
beefy-player = beefy-player final;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
class BeefyPlayer < Formula
|
||||||
|
include Language::Python::Virtualenv
|
||||||
|
|
||||||
|
desc "Terminal player for internet radio with a live spectrum analyser"
|
||||||
|
homepage "https://tildeforge.dev/beefcarver/beefy-player"
|
||||||
|
url "https://tildeforge.dev/beefcarver/beefy-player/releases/download/v1.2.2/beefy-player-1.2.2.tar.gz"
|
||||||
|
sha256 "0000000000000000000000000000000000000000000000000000000000000000" # TODO: release asset
|
||||||
|
license "WTFPL"
|
||||||
|
head "https://tildeforge.dev/beefcarver/beefy-player.git", branch: "main"
|
||||||
|
|
||||||
|
depends_on "python@3.14"
|
||||||
|
|
||||||
|
# numpy and pillow are large native builds. Depending on Homebrew's own
|
||||||
|
# formulae (which have bottles) keeps them out of the app's virtualenv build;
|
||||||
|
# the venv sees them through system site-packages.
|
||||||
|
depends_on "numpy"
|
||||||
|
depends_on "pillow"
|
||||||
|
|
||||||
|
# Runtime tools the app shells out to: mpv for audio, ffmpeg for the stream
|
||||||
|
# decode fallback of the spectrum analyser. Both are optional at runtime --
|
||||||
|
# the UI degrades gracefully -- but a radio player without them is not much fun.
|
||||||
|
depends_on "ffmpeg"
|
||||||
|
depends_on "mpv"
|
||||||
|
|
||||||
|
# Tells `brew update-python-resources` what to resolve: numpy/pillow come from
|
||||||
|
# the formulae above, and dbus-next is marker-gated to Linux upstream so it
|
||||||
|
# has to be requested explicitly for the Linux side of the tap.
|
||||||
|
pypi_packages exclude_packages: "numpy,pillow", extra_packages: "dbus-next"
|
||||||
|
|
||||||
|
def install
|
||||||
|
# system_site_packages is the default, so the excluded numpy/pillow from the
|
||||||
|
# formulae are importable from the virtualenv.
|
||||||
|
virtualenv_install_with_resources
|
||||||
|
end
|
||||||
|
|
||||||
|
def caveats
|
||||||
|
<<~EOS
|
||||||
|
The spectrum analyser follows what you hear. On macOS 14.2+ grant your
|
||||||
|
terminal permission under System Settings > Privacy & Security > System
|
||||||
|
Audio Recording, otherwise it falls back to decoding the stream.
|
||||||
|
|
||||||
|
Inside tmux, install `tmux` for image passthrough.
|
||||||
|
EOS
|
||||||
|
end
|
||||||
|
|
||||||
|
test do
|
||||||
|
assert_match version.to_s, shell_output("#{bin}/beefy-player --version")
|
||||||
|
# --offline keeps the test hermetic: cached or bundled stations only.
|
||||||
|
assert_match(/^# /, shell_output("#{bin}/beefy-player --offline --list"))
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Homebrew packaging for beefy-player
|
||||||
|
|
||||||
|
A Homebrew **tap** is just a second git repository that Homebrew clones next to
|
||||||
|
homebrew/core. There is no central submission, no review and no binary host:
|
||||||
|
`brew` reads the Ruby formula from the tap, downloads the pinned source tarball
|
||||||
|
and installs it on the user's machine.
|
||||||
|
|
||||||
|
This directory is the staging area for the tap repository, which lives at
|
||||||
|
**https://tildeforge.dev/beefcarver/homebrew-tap** (repo name `homebrew-tap`, so
|
||||||
|
the tap name is `beefcarver/tap`).
|
||||||
|
|
||||||
|
Because Homebrew's one-argument `brew tap` assumes GitHub, a Forgejo tap has to
|
||||||
|
be tapped with its URL, and Homebrew 6+ also wants an explicit trust.
|
||||||
|
|
||||||
|
## Install (user side)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap beefcarver/tap https://tildeforge.dev/beefcarver/homebrew-tap
|
||||||
|
brew trust --formula beefcarver/tap/beefy-player
|
||||||
|
brew install beefcarver/tap/beefy-player
|
||||||
|
```
|
||||||
|
|
||||||
|
`brew update` refreshes the tap from then on; `brew upgrade` picks up new
|
||||||
|
versions like any core formula. If the tap is mirrored to GitHub later,
|
||||||
|
`brew install beefcarver/tap/beefy-player` becomes a one-liner, because that
|
||||||
|
form auto-taps from GitHub and trusts only the formula it installs.
|
||||||
|
|
||||||
|
## Publishing the tap (maintainer side)
|
||||||
|
|
||||||
|
### 1. One tag per release, with a release asset
|
||||||
|
|
||||||
|
The formula pins a stable URL plus a SHA-256, so it needs an immutable tarball.
|
||||||
|
Do **not** point it at Forgejo's `/archive/v$VERSION.tar.gz` URL: those archives
|
||||||
|
are generated on demand and are not guaranteed byte-stable. Attach a tarball to
|
||||||
|
the release instead (Forgejo serves it at `/releases/download/<tag>/<file>`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VERSION=$(uv run python -c 'import beefy_player; print(beefy_player.__version__)')
|
||||||
|
git archive --format=tar.gz --prefix="beefy-player-$VERSION/" \
|
||||||
|
-o "beefy-player-$VERSION.tar.gz" "v$VERSION"
|
||||||
|
sha256sum "beefy-player-$VERSION.tar.gz"
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the tag on the mirror (`git push tildeforge "v$VERSION"`), upload the
|
||||||
|
tarball to the `v$VERSION` release, then paste the URL and checksum into
|
||||||
|
`Formula/b/beefy-player.rb`.
|
||||||
|
|
||||||
|
### 2. Resolve the Python resources
|
||||||
|
|
||||||
|
Every Python dependency (and its recursive dependencies) that is not provided by
|
||||||
|
another formula must be declared as a `resource` so Homebrew installs with
|
||||||
|
dependency resolution disabled. Never write these by hand:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap beefcarver/tap /path/to/homebrew-tap # local clone, or the URL
|
||||||
|
brew update-python-resources --print-only beefcarver/tap/beefy-player
|
||||||
|
brew update-python-resources beefcarver/tap/beefy-player
|
||||||
|
```
|
||||||
|
|
||||||
|
`pypi_packages exclude_packages: "numpy,pillow"` keeps the two native builds out
|
||||||
|
of the resource list — they come from the `numpy`/`pillow` formulae, which ship
|
||||||
|
bottles, and the virtualenv picks them up through system site-packages.
|
||||||
|
`extra_packages: "dbus-next"` makes sure the Linux-only dependency is recorded
|
||||||
|
even though the resolver runs on macOS.
|
||||||
|
|
||||||
|
In a third-party tap, `--ignore-errors` records anything that cannot be resolved
|
||||||
|
and leaves a `RESOURCE-ERROR` comment; the official taps do not allow that, a
|
||||||
|
personal one does.
|
||||||
|
|
||||||
|
### 3. Build, test and audit locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install --build-from-source beefcarver/tap/beefy-player
|
||||||
|
brew test beefcarver/tap/beefy-player
|
||||||
|
brew audit --strict beefcarver/tap/beefy-player
|
||||||
|
brew style beefcarver/tap/beefy-player
|
||||||
|
```
|
||||||
|
|
||||||
|
The formula's `test do` block runs `--version` and `--offline --list`, so it
|
||||||
|
needs no network and no audio device.
|
||||||
|
|
||||||
|
### 4. Push
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add Formula/b/beefy-player.rb && git commit -m 'beefy-player 1.2.2' && git push
|
||||||
|
```
|
||||||
|
|
||||||
|
## Version bumps
|
||||||
|
|
||||||
|
For each release: tag, upload the tarball, update `url`/`sha256`, rerun
|
||||||
|
`brew update-python-resources` (only needed when the dependency set or pins
|
||||||
|
changed), install from source, test, push. `brew bump-formula-pr` only works
|
||||||
|
against GitHub-hosted taps, so on Forgejo the bump is a `sed` plus a re-test.
|
||||||
|
|
||||||
|
Optional `livecheck` support, so `brew livecheck --tap beefcarver/tap` can spot
|
||||||
|
new tags:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
livecheck do
|
||||||
|
url "https://tildeforge.dev/beefcarver/beefy-player/tags"
|
||||||
|
regex(/^v?(\d+(?:\.\d+)+)$/i)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bottles (optional)
|
||||||
|
|
||||||
|
Without a `bottle do` block users build from source — for this app that means
|
||||||
|
filling a virtualenv with pure-Python resources while `numpy`, `pillow`, `mpv`
|
||||||
|
and `ffmpeg` arrive as bottles, so it is quick. Prebuilt bottles need Homebrew's
|
||||||
|
GitHub Actions workflows, which cannot run on Forgejo; skip them unless the tap
|
||||||
|
is also mirrored to GitHub.
|
||||||
|
|
||||||
|
## Why a tap and not homebrew-core
|
||||||
|
|
||||||
|
homebrew-core requires a tagged, immutable source URL and enough notability to
|
||||||
|
be accepted at all (roughly 75+ stars / 30+ forks, or coverage that makes a
|
||||||
|
project notable). A tap has none of those constraints, which is why upstream
|
||||||
|
taps are the normal route for a project this size. The formula here follows
|
||||||
|
core's conventions, so it could be submitted there later unchanged if the
|
||||||
|
project qualifies.
|
||||||
+10
-10
@@ -1,13 +1,13 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "somafm-tui"
|
name = "beefy-player"
|
||||||
version = "0.1.0"
|
version = "1.2.2"
|
||||||
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, Radio Paradise, dublab, Rinse FM and more)"
|
||||||
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", "tilderadio", "bassdrive", "radioparadise", "radiobrowser", "dublab"]
|
||||||
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,126 +0,0 @@
|
|||||||
"""Paths, persistent settings and tiny cache helpers.
|
|
||||||
|
|
||||||
Everything lives in the usual XDG places:
|
|
||||||
|
|
||||||
~/.config/somafm-tui/config.json favourites, volume, last station
|
|
||||||
~/.cache/somafm-tui/channels.json last good copy of somafm.com/channels.json
|
|
||||||
~/.cache/somafm-tui/playlists.json resolved .pls -> stream URLs
|
|
||||||
~/.cache/somafm-tui/songs/<id>.json last good copy of the song history
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from dataclasses import asdict, dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
APP_NAME = "somafm-tui"
|
|
||||||
|
|
||||||
|
|
||||||
def _xdg(env_var: str, fallback: str) -> Path:
|
|
||||||
value = os.environ.get(env_var)
|
|
||||||
if value:
|
|
||||||
return Path(value).expanduser()
|
|
||||||
return Path.home() / fallback
|
|
||||||
|
|
||||||
|
|
||||||
CONFIG_DIR = _xdg("XDG_CONFIG_HOME", ".config") / APP_NAME
|
|
||||||
CACHE_DIR = _xdg("XDG_CACHE_HOME", ".cache") / APP_NAME
|
|
||||||
RUNTIME_DIR = Path(os.environ.get("XDG_RUNTIME_DIR") or "/tmp") / APP_NAME
|
|
||||||
|
|
||||||
CONFIG_PATH = CONFIG_DIR / "config.json"
|
|
||||||
CHANNELS_CACHE = CACHE_DIR / "channels.json"
|
|
||||||
PLAYLIST_CACHE = CACHE_DIR / "playlists.json"
|
|
||||||
SONGS_CACHE_DIR = CACHE_DIR / "songs"
|
|
||||||
|
|
||||||
VISUALIZERS = ("both", "bars", "wave")
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_dirs() -> None:
|
|
||||||
for directory in (CONFIG_DIR, CACHE_DIR, SONGS_CACHE_DIR):
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def atomic_write_text(path: Path, text: str) -> None:
|
|
||||||
"""Write ``text`` to ``path`` without ever leaving a half-written file."""
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
tmp = path.with_name(f".{path.name}.tmp")
|
|
||||||
tmp.write_text(text, encoding="utf-8")
|
|
||||||
os.replace(tmp, path)
|
|
||||||
|
|
||||||
|
|
||||||
def read_json(path: Path) -> Any:
|
|
||||||
try:
|
|
||||||
return json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def write_json(path: Path, payload: Any) -> None:
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
atomic_write_text(path, json.dumps(payload, indent=2, ensure_ascii=False))
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class Config:
|
|
||||||
favorites: list[str] = field(default_factory=list)
|
|
||||||
last_station: str = "groovesalad"
|
|
||||||
volume: int = 70
|
|
||||||
visualizer: str = "both"
|
|
||||||
autoplay: bool = False
|
|
||||||
#: Publish what is playing to the desktop media session (MPRIS on Linux,
|
|
||||||
#: macOS Now Playing via mpv) and honour the media keys. The JSON key is
|
|
||||||
#: spelled ``mpris`` for backwards compatibility.
|
|
||||||
mpris: bool = True
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def load(cls) -> Config:
|
|
||||||
raw = read_json(CONFIG_PATH)
|
|
||||||
config = cls()
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
return config
|
|
||||||
|
|
||||||
favorites = raw.get("favorites")
|
|
||||||
if isinstance(favorites, list):
|
|
||||||
config.favorites = [str(item) for item in favorites if isinstance(item, str)]
|
|
||||||
|
|
||||||
last_station = raw.get("last_station")
|
|
||||||
if isinstance(last_station, str) and last_station:
|
|
||||||
config.last_station = last_station
|
|
||||||
|
|
||||||
volume = raw.get("volume")
|
|
||||||
if isinstance(volume, (int, float)):
|
|
||||||
config.volume = max(0, min(100, int(volume)))
|
|
||||||
|
|
||||||
visualizer = raw.get("visualizer")
|
|
||||||
if visualizer in VISUALIZERS:
|
|
||||||
config.visualizer = visualizer
|
|
||||||
|
|
||||||
if isinstance(raw.get("autoplay"), bool):
|
|
||||||
config.autoplay = raw["autoplay"]
|
|
||||||
if isinstance(raw.get("mpris"), bool):
|
|
||||||
config.mpris = raw["mpris"]
|
|
||||||
return config
|
|
||||||
|
|
||||||
def save(self) -> None:
|
|
||||||
try:
|
|
||||||
ensure_dirs()
|
|
||||||
atomic_write_text(
|
|
||||||
CONFIG_PATH, json.dumps(asdict(self), indent=2, ensure_ascii=False)
|
|
||||||
)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def is_favorite(self, station_id: str) -> bool:
|
|
||||||
return station_id in self.favorites
|
|
||||||
|
|
||||||
def toggle_favorite(self, station_id: str) -> bool:
|
|
||||||
if station_id in self.favorites:
|
|
||||||
self.favorites.remove(station_id)
|
|
||||||
return False
|
|
||||||
self.favorites.append(station_id)
|
|
||||||
return True
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Custom Textual widgets for somafm-tui."""
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
"""Header: logo, clock and a live ticker."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, cast
|
|
||||||
|
|
||||||
from rich.cells import cell_len
|
|
||||||
from rich.console import Group, RenderableType
|
|
||||||
from rich.text import Text
|
|
||||||
from textual.widget import Widget
|
|
||||||
|
|
||||||
from .common import (
|
|
||||||
SPECTRUM_STOPS,
|
|
||||||
clock_time,
|
|
||||||
date_label,
|
|
||||||
gradient,
|
|
||||||
marquee,
|
|
||||||
state_glyph,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ..app import SomaFMApp
|
|
||||||
|
|
||||||
LOGO = (
|
|
||||||
"▄▀▀▀ ▄▀▀▄ █▄ ▄█ ▄▀▀▄",
|
|
||||||
"▀▀▀▄ █ █ █ █ █ █▀▀█",
|
|
||||||
"▄▄▄▀ ▀▄▄▀ █ █ █ █",
|
|
||||||
)
|
|
||||||
|
|
||||||
MIDDLE = ("SOMA FM RADIO", "LISTENER SUPPORTED", "SAN FRANCISCO")
|
|
||||||
|
|
||||||
|
|
||||||
class HeaderBar(Widget):
|
|
||||||
can_focus = False
|
|
||||||
|
|
||||||
def render(self) -> RenderableType:
|
|
||||||
app = cast("SomaFMApp", self.app)
|
|
||||||
width = max(24, self.size.width)
|
|
||||||
height = max(1, self.size.height)
|
|
||||||
state = app.playing_state
|
|
||||||
glyph, colour = state_glyph(state)
|
|
||||||
|
|
||||||
logo_width = max(cell_len(row) for row in LOGO)
|
|
||||||
middle_width = max(cell_len(row) for row in MIDDLE)
|
|
||||||
right = (clock_time(), date_label(), f"vol {app.volume}%")
|
|
||||||
right_width = max(cell_len(item) for item in right)
|
|
||||||
|
|
||||||
show_middle = width >= logo_width + middle_width + 8
|
|
||||||
show_right = width >= logo_width + right_width + 8
|
|
||||||
if show_middle and show_right:
|
|
||||||
show_middle = width >= logo_width + 4 + middle_width + 2 + right_width
|
|
||||||
|
|
||||||
lines: list[Text] = []
|
|
||||||
for index, row in enumerate(LOGO):
|
|
||||||
line = Text(no_wrap=True)
|
|
||||||
for column, char in enumerate(row):
|
|
||||||
position = (
|
|
||||||
column / max(1, logo_width - 1) + index * 0.055
|
|
||||||
) % 1.0
|
|
||||||
line.append(
|
|
||||||
char, style=f"bold {gradient(SPECTRUM_STOPS, position)}"
|
|
||||||
)
|
|
||||||
if show_middle:
|
|
||||||
line.append(" " * 4)
|
|
||||||
middle = MIDDLE[index]
|
|
||||||
line.append(middle, style="dim")
|
|
||||||
# Keep the right block on one column even though the middle
|
|
||||||
# lines differ in length ("SOMA FM RADIO" is shorter than
|
|
||||||
# "LISTENER SUPPORTED").
|
|
||||||
line.append(" " * max(0, middle_width - cell_len(middle)))
|
|
||||||
if show_right:
|
|
||||||
used = logo_width
|
|
||||||
if show_middle:
|
|
||||||
used += 4 + middle_width
|
|
||||||
# Right-align the block *and* every item inside it: the clock,
|
|
||||||
# the date and the volume each keep their own length, so pad
|
|
||||||
# the shortest ones instead of left-aligning the whole block.
|
|
||||||
pad = max(1, width - used - right_width)
|
|
||||||
item = right[index]
|
|
||||||
line.append(" " * pad)
|
|
||||||
line.append(" " * max(0, right_width - cell_len(item)))
|
|
||||||
style = "bold" if index == 0 else "dim"
|
|
||||||
line.append(item, style=style)
|
|
||||||
lines.append(line)
|
|
||||||
|
|
||||||
if height >= 4:
|
|
||||||
ticker = Text(no_wrap=True)
|
|
||||||
prefix = f"{glyph} {state.upper():<8} "
|
|
||||||
ticker.append(prefix, style=f"bold {colour}")
|
|
||||||
station = app.current
|
|
||||||
if app.notice:
|
|
||||||
text = app.notice
|
|
||||||
text_style = "italic yellow"
|
|
||||||
elif station is not None:
|
|
||||||
song = station.now_playing
|
|
||||||
if song is not None:
|
|
||||||
text = f"{station.title} · {song.display_artist} — {song.display_title}"
|
|
||||||
else:
|
|
||||||
text = f"{station.title} · {station.description}"
|
|
||||||
text_style = ""
|
|
||||||
else:
|
|
||||||
text = "Pick a station — press enter to tune in"
|
|
||||||
text_style = "italic dim"
|
|
||||||
ticker.append(marquee(text, max(4, width - cell_len(prefix))), style=text_style)
|
|
||||||
lines.append(ticker)
|
|
||||||
|
|
||||||
return Group(*lines[:height])
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
"""Full-screen keyboard help."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import textwrap
|
|
||||||
|
|
||||||
from rich.console import Group
|
|
||||||
from rich.table import Table
|
|
||||||
from rich.text import Text
|
|
||||||
from textual.app import ComposeResult
|
|
||||||
from textual.screen import ModalScreen
|
|
||||||
from textual.widgets import Static
|
|
||||||
|
|
||||||
from .common import SPECTRUM_STOPS, gradient
|
|
||||||
|
|
||||||
HELP_ROWS: tuple[tuple[str, str], ...] = (
|
|
||||||
("↑ / ↓ j / k", "move through the station list"),
|
|
||||||
("enter", "tune in to the selected station"),
|
|
||||||
("space", "play / pause the current station"),
|
|
||||||
("s", "stop playback"),
|
|
||||||
("] / [", "next / previous station (tunes immediately)"),
|
|
||||||
("+ / -", "volume up / down"),
|
|
||||||
("m", "mute / unmute"),
|
|
||||||
("f", "favourite — favourites float to the top"),
|
|
||||||
("/", "filter stations by title, genre or description"),
|
|
||||||
("esc", "clear the filter / close overlays"),
|
|
||||||
("v", "cycle visualiser: mirrored bars → bars → wave"),
|
|
||||||
("r", "refresh station list and song history"),
|
|
||||||
("g / G", "jump to the top / bottom of the list"),
|
|
||||||
("? ", "this help"),
|
|
||||||
("q", "quit"),
|
|
||||||
)
|
|
||||||
|
|
||||||
ABOUT_COMMON = (
|
|
||||||
(
|
|
||||||
"Stations and song history come from somafm.com "
|
|
||||||
"(channels.json + songs/<station>.json)."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
ABOUT_LINUX = (
|
|
||||||
(
|
|
||||||
"The spectrum captures your actual PulseAudio/PipeWire output with "
|
|
||||||
"parec (SYNC); otherwise it falls back to a muted ffmpeg stream tap "
|
|
||||||
"(FFT, can drift) or a synthesized display (SIM)."
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"The desktop picks us up over MPRIS as a "
|
|
||||||
"somafmtui media player — try `playerctl -l` / `playerctl metadata`."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
ABOUT_MACOS = (
|
|
||||||
(
|
|
||||||
"The spectrum captures your Mac's real audio output (SYNC) with a "
|
|
||||||
"native Core Audio process tap — nothing to install. macOS asks for "
|
|
||||||
"System Audio Recording permission the first time; without it the "
|
|
||||||
"analyser falls back to a muted ffmpeg stream tap (FFT, can drift) "
|
|
||||||
"or a synthesized display (SIM)."
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"mpv publishes what is playing to the macOS Now Playing session, so "
|
|
||||||
"Control Center and the keyboard media keys control this app."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
ABOUT = ABOUT_COMMON + (ABOUT_MACOS if sys.platform == "darwin" else ABOUT_LINUX)
|
|
||||||
|
|
||||||
|
|
||||||
class HelpScreen(ModalScreen[None]):
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
table = Table.grid(padding=(0, 2), expand=False)
|
|
||||||
table.add_column(style="bold cyan", no_wrap=True, justify="right")
|
|
||||||
table.add_column(style="")
|
|
||||||
for key, description in HELP_ROWS:
|
|
||||||
table.add_row(key, description)
|
|
||||||
|
|
||||||
header = Text()
|
|
||||||
header.append("◤ ", style=f"bold {gradient(SPECTRUM_STOPS, 0.9)}")
|
|
||||||
header.append("SOMAFM TUI", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}")
|
|
||||||
header.append(" keyboard & credits", style="dim")
|
|
||||||
|
|
||||||
footer = Text()
|
|
||||||
for paragraph in ABOUT:
|
|
||||||
for line in textwrap.wrap(paragraph, width=70):
|
|
||||||
footer.append(line + "\n", style="dim")
|
|
||||||
|
|
||||||
body = Group(header, Text(""), table, Text(""), footer)
|
|
||||||
yield Static(body, id="help")
|
|
||||||
|
|
||||||
def on_key(self, event) -> None:
|
|
||||||
event.stop()
|
|
||||||
self.dismiss(None)
|
|
||||||
@@ -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
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
"""Station list with favourites, live markers and a filter."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, cast
|
|
||||||
|
|
||||||
from rich.cells import cell_len
|
|
||||||
from rich.console import Group, RenderableType
|
|
||||||
from rich.text import Text
|
|
||||||
from textual.widget import Widget
|
|
||||||
|
|
||||||
from .common import compact_count, truncate
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ..app import SomaFMApp
|
|
||||||
|
|
||||||
|
|
||||||
class StationList(Widget):
|
|
||||||
can_focus = False
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self.border_title = "STATIONS"
|
|
||||||
|
|
||||||
def set_filter(self, query: str) -> None:
|
|
||||||
safe_query = query.replace("[", "(").replace("]", ")") if query else ""
|
|
||||||
title = f"STATIONS · /{safe_query}" if safe_query else "STATIONS"
|
|
||||||
if self.border_title != title:
|
|
||||||
self.border_title = title
|
|
||||||
|
|
||||||
def on_mouse_scroll_down(self, event) -> None:
|
|
||||||
cast("SomaFMApp", self.app).action_cursor_down()
|
|
||||||
|
|
||||||
def on_mouse_scroll_up(self, event) -> None:
|
|
||||||
cast("SomaFMApp", self.app).action_cursor_up()
|
|
||||||
|
|
||||||
def render(self) -> RenderableType:
|
|
||||||
app = cast("SomaFMApp", self.app)
|
|
||||||
width = max(12, self.size.width)
|
|
||||||
height = max(1, self.size.height)
|
|
||||||
view = app.view
|
|
||||||
|
|
||||||
if not view:
|
|
||||||
message = "no stations match" if app.query else "loading stations…"
|
|
||||||
return Group(Text(truncate(message, width), style="dim italic"))
|
|
||||||
|
|
||||||
first = 0
|
|
||||||
if len(view) > height:
|
|
||||||
first = max(0, min(app.index - height // 2, len(view) - height))
|
|
||||||
|
|
||||||
lines: list[Text] = []
|
|
||||||
for offset in range(min(height, len(view))):
|
|
||||||
index = first + offset
|
|
||||||
station = view[index]
|
|
||||||
selected = index == app.index
|
|
||||||
current = app.current is not None and app.current.id == station.id
|
|
||||||
playing = current and app.playing_state not in ("stopped", "error")
|
|
||||||
|
|
||||||
marker = "▌" if selected else " "
|
|
||||||
star = "★" if station.favorite else " "
|
|
||||||
play = (
|
|
||||||
("‖" if app.playing_state == "paused" else "▶") if current else " "
|
|
||||||
)
|
|
||||||
|
|
||||||
count_text = ""
|
|
||||||
count_width = 0
|
|
||||||
if width >= 26 and station.listeners > 0:
|
|
||||||
count_text = f"{compact_count(station.listeners)} ♪"
|
|
||||||
count_width = cell_len(count_text) + 1
|
|
||||||
|
|
||||||
prefix = marker + star + play + " "
|
|
||||||
available = width - cell_len(prefix) - count_width
|
|
||||||
title = truncate(station.title, max(4, available))
|
|
||||||
gap = max(0, width - cell_len(prefix) - cell_len(title) - count_width)
|
|
||||||
|
|
||||||
if selected:
|
|
||||||
base = ""
|
|
||||||
marker_style = "magenta"
|
|
||||||
title_style = "bold underline"
|
|
||||||
count_style = "bold"
|
|
||||||
elif current:
|
|
||||||
base = ""
|
|
||||||
marker_style = "cyan"
|
|
||||||
title_style = "cyan"
|
|
||||||
count_style = "dim"
|
|
||||||
else:
|
|
||||||
base = ""
|
|
||||||
marker_style = ""
|
|
||||||
title_style = ""
|
|
||||||
count_style = "dim"
|
|
||||||
|
|
||||||
row = Text(no_wrap=True, style=base)
|
|
||||||
row.append(marker, style=marker_style)
|
|
||||||
row.append(star, style="bold yellow" if station.favorite else "")
|
|
||||||
row.append(play, style="cyan" if playing else "")
|
|
||||||
row.append(" ")
|
|
||||||
row.append(title, style=title_style)
|
|
||||||
row.append(" " * gap)
|
|
||||||
if count_text:
|
|
||||||
row.append(count_text, style=count_style)
|
|
||||||
used = cell_len(prefix) + cell_len(title) + gap + cell_len(count_text)
|
|
||||||
if used < width:
|
|
||||||
row.append(" " * (width - used))
|
|
||||||
lines.append(row)
|
|
||||||
|
|
||||||
return Group(*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
|
||||||
+21
-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,19 @@ 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
|
||||||
|
# HLS is the player's job, not ours: mpv reads the playlist itself
|
||||||
|
assert base.is_playlist("https://x/live/index.m3u8") is False
|
||||||
|
assert base.is_direct_stream("https://x/live/index.m3u8") is True
|
||||||
|
|
||||||
|
|
||||||
def test_parse_songs_and_dates() -> None:
|
def test_parse_songs_and_dates() -> None:
|
||||||
@@ -143,7 +151,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 +160,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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+459
-15
@@ -6,21 +6,33 @@ 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, Station
|
||||||
from somafm_tui.widgets.stage import StagePanel
|
from beefy_player.providers.base import StationPage
|
||||||
from somafm_tui.widgets.station_list import StationList
|
from beefy_player.widgets.art import ArtPane
|
||||||
|
from beefy_player.widgets.help import HelpScreen
|
||||||
|
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()
|
||||||
|
|
||||||
@@ -59,8 +71,10 @@ async def test_boot_navigation_filter_favourite_visualizer_help() -> None:
|
|||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
assert app.current is not None
|
assert app.current is not None
|
||||||
assert app.current.id == "defcon"
|
assert app.current.id == "defcon"
|
||||||
assert app.query == ""
|
assert app.query == "defcon"
|
||||||
assert len(app.view) == len(app.stations)
|
assert [station.id for station in app.view] == ["defcon"]
|
||||||
|
assert not search.has_class("visible")
|
||||||
|
assert app.focused is None
|
||||||
assert app.playing_state in ("stopped", "error")
|
assert app.playing_state in ("stopped", "error")
|
||||||
|
|
||||||
app.action_favorite()
|
app.action_favorite()
|
||||||
@@ -70,11 +84,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()
|
||||||
@@ -89,9 +103,317 @@ async def test_boot_navigation_filter_favourite_visualizer_help() -> None:
|
|||||||
assert "DEF CON" in text.upper()
|
assert "DEF CON" in text.upper()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_escape_leaves_the_filter_sticky_and_a_second_escape_clears_it() -> None:
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(110, 36)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
await pilot.press("slash")
|
||||||
|
for char in "defcon":
|
||||||
|
await pilot.press(char)
|
||||||
|
await pilot.pause()
|
||||||
|
search = app.query_one("#search")
|
||||||
|
assert search.has_class("visible")
|
||||||
|
assert app.query == "defcon"
|
||||||
|
assert [station.id for station in app.view] == ["defcon"]
|
||||||
|
|
||||||
|
await pilot.press("escape")
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.query == "defcon"
|
||||||
|
assert search.value == "defcon"
|
||||||
|
assert not search.has_class("visible")
|
||||||
|
assert app.focused is None
|
||||||
|
assert [station.id for station in app.view] == ["defcon"]
|
||||||
|
assert "defcon" in app.query_one(StationList).border_title
|
||||||
|
|
||||||
|
app.action_favorite()
|
||||||
|
await pilot.pause()
|
||||||
|
assert "defcon" in app.config.favorites
|
||||||
|
assert app.query == "defcon"
|
||||||
|
assert [station.id for station in app.view] == ["defcon"]
|
||||||
|
|
||||||
|
await pilot.press("escape")
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.query == ""
|
||||||
|
assert search.value == ""
|
||||||
|
assert len(app.view) == len(app.stations)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enter_keeps_the_filter_sticky_like_the_first_escape() -> None:
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(110, 36)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("slash")
|
||||||
|
for char in "defcon":
|
||||||
|
await pilot.press(char)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
await pilot.press("enter")
|
||||||
|
await pilot.pause()
|
||||||
|
search = app.query_one("#search")
|
||||||
|
assert app.current is not None
|
||||||
|
assert app.current.id == "defcon"
|
||||||
|
assert app.query == "defcon"
|
||||||
|
assert [station.id for station in app.view] == ["defcon"]
|
||||||
|
assert not search.has_class("visible")
|
||||||
|
|
||||||
|
await pilot.press("escape")
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.query == ""
|
||||||
|
assert len(app.view) == len(app.stations)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_directory_search_uses_the_server_not_the_loaded_list(monkeypatch) -> None:
|
||||||
|
app = BeefyPlayerApp(
|
||||||
|
provider="radiobrowser", autoplay=False, offline=True, audio=False
|
||||||
|
)
|
||||||
|
async with app.run_test(size=(110, 36)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
app.offline = False
|
||||||
|
|
||||||
|
found = Station(
|
||||||
|
id="deep-jazz",
|
||||||
|
title="Deep Jazz",
|
||||||
|
description="France · french",
|
||||||
|
genre="jazz",
|
||||||
|
provider="radiobrowser",
|
||||||
|
)
|
||||||
|
queries: list[str] = []
|
||||||
|
|
||||||
|
async def fake_search(self, client, query):
|
||||||
|
queries.append(query)
|
||||||
|
return [found]
|
||||||
|
|
||||||
|
monkeypatch.setattr(type(app.provider), "search_stations", fake_search)
|
||||||
|
|
||||||
|
app.query = "jazz"
|
||||||
|
app._rebuild_view()
|
||||||
|
assert not any(station.id == "deep-jazz" for station in app.view)
|
||||||
|
|
||||||
|
worker = app._search_directory("jazz")
|
||||||
|
await worker.wait()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert queries == ["jazz"]
|
||||||
|
assert [station.id for station in app.view] == ["deep-jazz"]
|
||||||
|
assert app._find_station("deep-jazz") is found
|
||||||
|
|
||||||
|
app._tune(found)
|
||||||
|
assert any(station.id == "deep-jazz" for station in app.stations)
|
||||||
|
app._set_stations(
|
||||||
|
[Station(id="other", title="Other", provider="radiobrowser")]
|
||||||
|
)
|
||||||
|
assert app.current.id == "deep-jazz"
|
||||||
|
assert any(station.id == "deep-jazz" for station in app.stations)
|
||||||
|
|
||||||
|
app.query = ""
|
||||||
|
app.search_results = None
|
||||||
|
app._search_results_query = ""
|
||||||
|
app._rebuild_view()
|
||||||
|
assert len(app.view) == len(app.stations)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_refresh_replaces_a_fallback_station() -> None:
|
||||||
|
app = BeefyPlayerApp(
|
||||||
|
provider="radiobrowser", autoplay=False, offline=True, audio=False
|
||||||
|
)
|
||||||
|
async with app.run_test(size=(110, 36)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.current is not None
|
||||||
|
assert app.current.id.startswith("fallback-")
|
||||||
|
|
||||||
|
app._set_stations(
|
||||||
|
[Station(id="real", title="Real Station", provider="radiobrowser")]
|
||||||
|
)
|
||||||
|
assert app.current.id == "real"
|
||||||
|
assert [station.id for station in app.stations] == ["real"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_favoriting_a_loaded_station_from_search_updates_it_in_place() -> None:
|
||||||
|
app = BeefyPlayerApp(
|
||||||
|
provider="radiobrowser", autoplay=False, offline=True, audio=False
|
||||||
|
)
|
||||||
|
async with app.run_test(size=(110, 36)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
existing = app.stations[0]
|
||||||
|
search_copy = Station(
|
||||||
|
id=existing.id, title=existing.title, provider="radiobrowser"
|
||||||
|
)
|
||||||
|
|
||||||
|
app.query = "jazz"
|
||||||
|
app._set_search_results("jazz", [search_copy])
|
||||||
|
assert app.view[0] is existing
|
||||||
|
|
||||||
|
app.index = 0
|
||||||
|
app.action_favorite()
|
||||||
|
await pilot.pause()
|
||||||
|
assert existing.favorite is True
|
||||||
|
assert app.config.is_favorite(existing.id)
|
||||||
|
|
||||||
|
app._close_search(clear=True)
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.view[0].id == existing.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_favoriting_a_directory_result_keeps_it_after_clearing_the_filter(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
app = BeefyPlayerApp(
|
||||||
|
provider="radiobrowser", autoplay=False, offline=True, audio=False
|
||||||
|
)
|
||||||
|
async with app.run_test(size=(110, 36)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
app.offline = False
|
||||||
|
|
||||||
|
found = Station(
|
||||||
|
id="deep-jazz", title="Deep Jazz", provider="radiobrowser"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_search(self, client, query):
|
||||||
|
return [found]
|
||||||
|
|
||||||
|
monkeypatch.setattr(type(app.provider), "search_stations", fake_search)
|
||||||
|
|
||||||
|
await pilot.press("slash")
|
||||||
|
for char in "jazz":
|
||||||
|
await pilot.press(char)
|
||||||
|
await pilot.pause(0.4)
|
||||||
|
assert [station.id for station in app.view] == ["deep-jazz"]
|
||||||
|
|
||||||
|
await pilot.press("escape")
|
||||||
|
await pilot.pause()
|
||||||
|
app.action_favorite()
|
||||||
|
await pilot.pause()
|
||||||
|
assert found.favorite is True
|
||||||
|
assert "deep-jazz" in app.config.favorites
|
||||||
|
|
||||||
|
await pilot.press("escape")
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.query == ""
|
||||||
|
assert app.view[0].id == "deep-jazz"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_missing_favorites_load_with_the_first_directory_page(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
app = BeefyPlayerApp(
|
||||||
|
provider="radiobrowser", autoplay=False, offline=True, audio=False
|
||||||
|
)
|
||||||
|
async with app.run_test(size=(110, 36)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
app.offline = False
|
||||||
|
|
||||||
|
favourite = Station(
|
||||||
|
id="12345678-1234-1234-1234-123456789abc",
|
||||||
|
title="Favourite",
|
||||||
|
provider="radiobrowser",
|
||||||
|
)
|
||||||
|
app.config.favorites = [favourite.id]
|
||||||
|
page = StationPage(
|
||||||
|
stations=[
|
||||||
|
Station(id="page-0", title="Page Zero", provider="radiobrowser")
|
||||||
|
],
|
||||||
|
next_offset=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_page(self, client, offset=0):
|
||||||
|
return page
|
||||||
|
|
||||||
|
async def fake_by_ids(self, client, station_ids):
|
||||||
|
assert station_ids == [favourite.id]
|
||||||
|
return [favourite]
|
||||||
|
|
||||||
|
monkeypatch.setattr(type(app.provider), "fetch_station_page", fake_page)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
type(app.provider), "fetch_stations_by_ids", fake_by_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
worker = app._refresh_stations()
|
||||||
|
await worker.wait()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert any(
|
||||||
|
station.id == favourite.id and station.favorite
|
||||||
|
for station in app.stations
|
||||||
|
)
|
||||||
|
assert app.view[0].id == favourite.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_paginated_catalogue_loads_the_next_page_near_the_end(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
app = BeefyPlayerApp(
|
||||||
|
provider="radiobrowser", autoplay=False, offline=True, audio=False
|
||||||
|
)
|
||||||
|
async with app.run_test(size=(110, 36)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
app.offline = False
|
||||||
|
|
||||||
|
first = [
|
||||||
|
Station(
|
||||||
|
id=f"s{index}", title=f"Station {index}", provider="radiobrowser"
|
||||||
|
)
|
||||||
|
for index in range(100)
|
||||||
|
]
|
||||||
|
app._set_stations(first)
|
||||||
|
app._reset_directory_paging(first, has_more=True)
|
||||||
|
assert app._directory_offset == 100
|
||||||
|
assert app._directory_exhausted is False
|
||||||
|
|
||||||
|
app.index = 0
|
||||||
|
app._maybe_load_more_stations()
|
||||||
|
assert app._loading_more is False
|
||||||
|
|
||||||
|
seen: list[int] = []
|
||||||
|
|
||||||
|
async def fake_page(self, client, offset=0):
|
||||||
|
seen.append(offset)
|
||||||
|
return StationPage(
|
||||||
|
stations=[
|
||||||
|
Station(id="extra", title="Extra", provider="radiobrowser")
|
||||||
|
],
|
||||||
|
next_offset=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(type(app.provider), "fetch_station_page", fake_page)
|
||||||
|
app.index = 99
|
||||||
|
worker = app._load_more_stations()
|
||||||
|
await worker.wait()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert seen == [100]
|
||||||
|
assert any(station.id == "extra" for station in app.stations)
|
||||||
|
assert app._directory_exhausted is True
|
||||||
|
assert app.view[app.index].id == "s99"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_popularity_ordered_catalogue_keeps_its_order() -> None:
|
||||||
|
app = BeefyPlayerApp(
|
||||||
|
provider="radiobrowser", autoplay=False, offline=True, audio=False
|
||||||
|
)
|
||||||
|
async with app.run_test(size=(110, 36)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
app._set_stations(
|
||||||
|
[
|
||||||
|
Station(id="zulu", title="Zulu", provider="radiobrowser"),
|
||||||
|
Station(id="alpha", title="Alpha", provider="radiobrowser"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert [station.title for station in app.view] == ["Zulu", "Alpha"]
|
||||||
|
|
||||||
|
|
||||||
@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 +444,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 +472,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 +494,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.
|
||||||
@@ -177,3 +571,53 @@ async def test_media_session_commands_drive_the_app() -> None:
|
|||||||
app.media_actions.put("volume:25")
|
app.media_actions.put("volume:25")
|
||||||
await pilot.pause(0.2)
|
await pilot.pause(0.2)
|
||||||
assert app.volume == 25
|
assert app.volume == 25
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_menus_take_the_inline_artwork_down_and_put_it_back(monkeypatch) -> None:
|
||||||
|
"""Terminal images sit over a modal unless the image is deleted first."""
|
||||||
|
calls: list[str] = []
|
||||||
|
monkeypatch.setattr(ArtPane, "suspend", lambda self: calls.append("suspend"))
|
||||||
|
monkeypatch.setattr(ArtPane, "resume", lambda self: calls.append("resume"))
|
||||||
|
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(100, 30)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
await pilot.press("question_mark")
|
||||||
|
await pilot.pause()
|
||||||
|
assert calls == ["suspend"]
|
||||||
|
await pilot.press("x")
|
||||||
|
await pilot.pause()
|
||||||
|
assert calls == ["suspend", "resume"]
|
||||||
|
|
||||||
|
await pilot.press("p")
|
||||||
|
await pilot.pause()
|
||||||
|
assert calls[-1] == "suspend"
|
||||||
|
await pilot.press("escape")
|
||||||
|
await pilot.pause()
|
||||||
|
assert calls[-1] == "resume"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_mosaic_gives_way_to_a_real_image(monkeypatch) -> None:
|
||||||
|
"""The pattern must not peek out around a letterboxed picture."""
|
||||||
|
from beefy_player.widgets import art as art_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(art_module.artwork, "supported", lambda: True)
|
||||||
|
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(110, 34)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
pane = app.query_one(ArtPane)
|
||||||
|
assert pane.display, "the artwork pane should be on screen at this size"
|
||||||
|
|
||||||
|
app.artwork_bytes = None
|
||||||
|
patterned = [line.plain for line in pane.render().renderables]
|
||||||
|
assert patterned and any(line.strip() for line in patterned)
|
||||||
|
|
||||||
|
app.artwork_bytes = b"pretend this is a png"
|
||||||
|
blank = [line.plain for line in pane.render().renderables]
|
||||||
|
assert len(blank) == len(patterned)
|
||||||
|
assert len(blank[0]) == len(patterned[0])
|
||||||
|
assert all(not line.strip() for line in blank)
|
||||||
+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)
|
||||||
|
|||||||
+69
-10
@@ -6,12 +6,23 @@ 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), alpha: int = 180) -> bytes:
|
||||||
buffer = BytesIO()
|
buffer = BytesIO()
|
||||||
Image.new("RGBA", size, (20, 180, 220, 180)).save(buffer, format="PNG")
|
Image.new("RGBA", size, (20, 180, 220, alpha)).save(buffer, format="PNG")
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def ink_bbox(image: Image.Image):
|
||||||
|
"""Where the artwork itself is, ignoring transparent or black padding."""
|
||||||
|
return image.convert("RGB").getbbox()
|
||||||
|
|
||||||
|
|
||||||
|
def opaque_png_bytes(size=(400, 200)) -> bytes:
|
||||||
|
buffer = BytesIO()
|
||||||
|
Image.new("RGB", size, (20, 180, 220)).save(buffer, format="PNG")
|
||||||
return buffer.getvalue()
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
@@ -28,17 +39,65 @@ 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_letterboxes_inside_the_cell_box() -> None:
|
||||||
fitted = artwork.fit_image(png_bytes(), 13, 8)
|
fitted = artwork.fit_image(png_bytes((400, 200)), 13, 8)
|
||||||
assert fitted is not None
|
assert fitted is not None
|
||||||
cell_width, cell_height = artwork.cell_size()
|
cell_width, cell_height = artwork.cell_size()
|
||||||
expected = (13 * cell_width) / (8 * cell_height)
|
assert (fitted.width, fitted.height) == (13 * cell_width, 8 * cell_height)
|
||||||
assert fitted.width / fitted.height == pytest.approx(expected, rel=0.01)
|
|
||||||
|
# the ink keeps its own 2:1 aspect instead of being cropped to the box
|
||||||
|
ink = ink_bbox(fitted)
|
||||||
|
assert ink is not None
|
||||||
|
left, top, right, bottom = ink
|
||||||
|
assert (right - left) / (bottom - top) == pytest.approx(2.0, rel=0.02)
|
||||||
|
# a wide image is padded above and below, and spans the full width
|
||||||
|
assert left == 0 and right == fitted.width
|
||||||
|
assert top > 0 and bottom < fitted.height
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_image_with_alpha_gets_a_black_backing() -> None:
|
||||||
|
"""Transparent artwork is drawn over the mosaic; black keeps it readable."""
|
||||||
|
fitted = artwork.fit_image(png_bytes((400, 200)), 13, 8)
|
||||||
|
assert fitted is not None
|
||||||
|
# every pixel is opaque now, and the padding is black rather than mosaic
|
||||||
|
assert fitted.getchannel("A").getextrema() == (255, 255)
|
||||||
|
assert fitted.getpixel((0, 0)) == (0, 0, 0, 255)
|
||||||
|
# the ink is still letterboxed, not cropped: its own 2:1 aspect survives
|
||||||
|
ink = ink_bbox(fitted)
|
||||||
|
assert ink is not None
|
||||||
|
left, top, right, bottom = ink
|
||||||
|
assert (right - left) / (bottom - top) == pytest.approx(2.0, rel=0.02)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_opaque_image_keeps_the_transparent_padding() -> None:
|
||||||
|
"""No alpha channel: nothing to hide, so the mosaic shows through."""
|
||||||
|
fitted = artwork.fit_image(opaque_png_bytes((400, 200)), 13, 8)
|
||||||
|
assert fitted is not None
|
||||||
|
assert fitted.getpixel((0, 0))[3] == 0
|
||||||
|
ink = ink_bbox(fitted)
|
||||||
|
assert ink is not None
|
||||||
|
left, top, right, bottom = ink
|
||||||
|
assert (right - left) / (bottom - top) == pytest.approx(2.0, rel=0.02)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fit_image_centres_a_tall_image() -> None:
|
||||||
|
fitted = artwork.fit_image(png_bytes((100, 400)), 13, 8)
|
||||||
|
assert fitted is not None
|
||||||
|
ink = ink_bbox(fitted)
|
||||||
|
assert ink is not None
|
||||||
|
left, _, right, _ = ink
|
||||||
|
assert left > 0 and right < fitted.width
|
||||||
|
assert right - left == pytest.approx(fitted.height / 4, rel=0.05)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fit_image_rejects_garbage() -> None:
|
||||||
assert artwork.fit_image(b"not an image", 13, 8) is None
|
assert artwork.fit_image(b"not an image", 13, 8) is 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")
|
||||||
|
)
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
"""Bassdrive: Icecast status, show titles, the homepage schedule, rolling history."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from beefy_player.models import Broadcast
|
||||||
|
from beefy_player.providers.bassdrive import BassdriveProvider, api
|
||||||
|
|
||||||
|
STATUS = {
|
||||||
|
"icestats": {
|
||||||
|
"source": [
|
||||||
|
{
|
||||||
|
"listenurl": "https://ice.bassdrive.net:443/stream",
|
||||||
|
"bitrate": 128,
|
||||||
|
"server_type": "audio/mpeg",
|
||||||
|
"listeners": 31,
|
||||||
|
"display-title": "Ben XO :: LIVE :: Night Shift Over [discord.gg/bassdrive]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"listenurl": "https://ice.bassdrive.net:443/stream56",
|
||||||
|
"bitrate": 56,
|
||||||
|
"server_type": "audio/aacp",
|
||||||
|
"listeners": 5,
|
||||||
|
"display-title": "Ben XO :: LIVE :: Night Shift Over [discord.gg/bassdrive]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"listenurl": "https://ice.bassdrive.net:443/stream32",
|
||||||
|
"bitrate": 32,
|
||||||
|
"server_type": "audio/aacp",
|
||||||
|
"listeners": 2,
|
||||||
|
"display-title": "Ben XO :: LIVE :: Night Shift Over [discord.gg/bassdrive]",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Trimmed from the real homepage: the same markup the site ships.
|
||||||
|
SCHEDULE_PAGE = """
|
||||||
|
<ul class="schedule">
|
||||||
|
<li>
|
||||||
|
<a href="radio/get-dipped">
|
||||||
|
<p class="title">Get Dipped <span class="location">Los Angeles, CA</span></p>
|
||||||
|
<span class="time">
|
||||||
|
<local-time datetime="2026-11-03T19:00:00-05:00" hour="numeric"></local-time> -
|
||||||
|
<local-time datetime="2026-11-03T21:00:00-05:00" hour="numeric"></local-time>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="radio/drumobsession">
|
||||||
|
<p class="title">DrumObsession <span class="location">Poznan, Poland</span></p>
|
||||||
|
<span class="time">
|
||||||
|
<local-time datetime="2026-11-03T11:00:00-05:00" hour="numeric"></local-time> -
|
||||||
|
<local-time datetime="2026-11-03T13:00:00-05:00" hour="numeric"></local-time>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fresh_memo():
|
||||||
|
api.reset_schedule_memo()
|
||||||
|
yield
|
||||||
|
api.reset_schedule_memo()
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_title_reads_both_conventions() -> None:
|
||||||
|
assert api.split_title("Ben XO :: LIVE :: Night Shift Over [promo]") == (
|
||||||
|
"Ben XO",
|
||||||
|
"Night Shift Over",
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
# plenty of shows simply append LIVE
|
||||||
|
assert api.split_title("Sohl Patrol LIVE") == ("", "Sohl Patrol", True)
|
||||||
|
assert api.split_title("Bassdrive Auto DJ") == ("", "Bassdrive Auto DJ", False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mounts_become_playlists_best_first_and_listeners_add_up() -> None:
|
||||||
|
playlists = api.playlists_from_status(STATUS)
|
||||||
|
assert [entry["bitrate"] for entry in playlists] == ["128", "56", "32"]
|
||||||
|
assert [entry["quality"] for entry in playlists] == ["highest", "high", "low"]
|
||||||
|
assert playlists[0]["format"] == "mp3" and playlists[1]["format"] == "aacp"
|
||||||
|
assert api.status_listeners(STATUS) == 38
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_schedule_reads_the_homepage_grid() -> None:
|
||||||
|
entries = api.parse_schedule(SCHEDULE_PAGE)
|
||||||
|
assert [entry.name for entry in entries] == ["DrumObsession", "Get Dipped"]
|
||||||
|
assert entries[0].location == "Poznan, Poland"
|
||||||
|
assert entries[0].start == datetime(2026, 11, 3, 11, tzinfo=timezone(timedelta(hours=-5)))
|
||||||
|
assert entries[0].end == datetime(2026, 11, 3, 13, tzinfo=timezone(timedelta(hours=-5)))
|
||||||
|
# the schedule list itself is not a schedule slot
|
||||||
|
assert api.parse_schedule("<ul><li>nothing here</li></ul>") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_roll_forward_repeats_the_week() -> None:
|
||||||
|
start = datetime(2026, 11, 3, 11, tzinfo=timezone(timedelta(hours=-5)))
|
||||||
|
entry = Broadcast(
|
||||||
|
name="DrumObsession",
|
||||||
|
start=start,
|
||||||
|
end=start + timedelta(hours=2),
|
||||||
|
)
|
||||||
|
now = start + timedelta(days=1)
|
||||||
|
rolled = api.roll_forward([entry], now)
|
||||||
|
assert len(rolled) == 1
|
||||||
|
assert rolled[0].start == start + timedelta(days=7)
|
||||||
|
assert rolled[0].start >= now
|
||||||
|
# an entry that is over and cannot be rolled into the future is dropped
|
||||||
|
far = start + timedelta(days=40)
|
||||||
|
assert api.roll_forward([entry], far) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_station_maps_the_title_and_finds_the_live_location() -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
slot = Broadcast(
|
||||||
|
name="Night Shift Over",
|
||||||
|
location="London, UK",
|
||||||
|
start=now - timedelta(minutes=30),
|
||||||
|
end=now + timedelta(minutes=30),
|
||||||
|
)
|
||||||
|
station = api.station_from_status(STATUS, [slot])
|
||||||
|
assert station is not None
|
||||||
|
assert station.id == "bassdrive"
|
||||||
|
assert station.dj == "Ben XO"
|
||||||
|
assert station.presenter == "live from London, UK"
|
||||||
|
assert station.now_playing is not None
|
||||||
|
assert station.now_playing.title == "Night Shift Over"
|
||||||
|
assert station.now_playing.artist == "Ben XO"
|
||||||
|
assert station.listeners == 38
|
||||||
|
assert station.format_label == "mp3 · 128k"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_title_without_a_live_marker_still_counts_when_the_slot_is_running() -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
status = {
|
||||||
|
"icestats": {
|
||||||
|
"source": [
|
||||||
|
{
|
||||||
|
"listenurl": "https://ice.bassdrive.net:443/stream",
|
||||||
|
"bitrate": 128,
|
||||||
|
"server_type": "audio/mpeg",
|
||||||
|
"listeners": 3,
|
||||||
|
"display-title": "Sohl Patrol",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slot = Broadcast(
|
||||||
|
name="Sohl Patrol",
|
||||||
|
location="White Plains, NY, USA",
|
||||||
|
start=now - timedelta(minutes=10),
|
||||||
|
end=now + timedelta(minutes=50),
|
||||||
|
)
|
||||||
|
live = api.live_broadcast(status, [slot], now)
|
||||||
|
assert live is not None and live.live is True
|
||||||
|
assert live.location == "White Plains, NY, USA"
|
||||||
|
|
||||||
|
|
||||||
|
def test_station_without_title_or_mounts_is_not_a_station() -> None:
|
||||||
|
assert api.station_from_status({"icestats": {}}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def client_for(
|
||||||
|
status_payload,
|
||||||
|
page: str = "",
|
||||||
|
*,
|
||||||
|
status_code: int = 200,
|
||||||
|
page_code: int = 200,
|
||||||
|
) -> httpx.AsyncClient:
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if str(request.url).endswith("status-json.xsl"):
|
||||||
|
return httpx.Response(status_code, json=status_payload)
|
||||||
|
return httpx.Response(page_code, text=page)
|
||||||
|
|
||||||
|
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||||
|
|
||||||
|
|
||||||
|
async def songs_for(payload, station_id: str = "bassdrive"):
|
||||||
|
async with client_for(payload) as client:
|
||||||
|
return await api.fetch_songs(client, station_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_schedule_puts_the_live_show_first_and_caches_the_grid() -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
payload = {
|
||||||
|
"icestats": {
|
||||||
|
"source": [
|
||||||
|
{
|
||||||
|
"listenurl": "https://ice.bassdrive.net:443/stream",
|
||||||
|
"bitrate": 128,
|
||||||
|
"server_type": "audio/mpeg",
|
||||||
|
"listeners": 9,
|
||||||
|
"display-title": "Get Dipped LIVE",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# the grid is generated relative to now so the live slot is really running
|
||||||
|
local = now.astimezone(timezone(timedelta(hours=-5)))
|
||||||
|
start = local - timedelta(minutes=15)
|
||||||
|
end = local + timedelta(minutes=45)
|
||||||
|
page = SCHEDULE_PAGE.replace("2026-11-03T19:00:00-05:00", start.isoformat()).replace(
|
||||||
|
"2026-11-03T21:00:00-05:00", end.isoformat()
|
||||||
|
)
|
||||||
|
async with client_for(payload, page) as client:
|
||||||
|
broadcast_list = await api.fetch_schedule(client)
|
||||||
|
|
||||||
|
assert broadcast_list[0].live is True
|
||||||
|
assert broadcast_list[0].name == "Get Dipped"
|
||||||
|
assert broadcast_list[0].location == "Los Angeles, CA"
|
||||||
|
assert broadcast_list[0].start.hour == start.hour
|
||||||
|
assert all(not item.live for item in broadcast_list[1:])
|
||||||
|
# the grid was written for offline starts
|
||||||
|
assert api.load_cached_schedule()
|
||||||
|
# ... and the second fetch reuses the memo instead of the network
|
||||||
|
async with client_for(payload, page_code=500) as client:
|
||||||
|
assert await api.fetch_schedule(client)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_history_is_a_rolling_record_of_shows() -> None:
|
||||||
|
first = await songs_for(STATUS)
|
||||||
|
assert [song.title for song in first] == ["Night Shift Over"]
|
||||||
|
|
||||||
|
# same show: the first-seen time is kept
|
||||||
|
again = await songs_for(STATUS)
|
||||||
|
assert again[0].played_at == first[0].played_at
|
||||||
|
|
||||||
|
other = {
|
||||||
|
"icestats": {
|
||||||
|
"source": [
|
||||||
|
{
|
||||||
|
"listenurl": "https://ice.bassdrive.net:443/stream",
|
||||||
|
"bitrate": 128,
|
||||||
|
"server_type": "audio/mpeg",
|
||||||
|
"listeners": 1,
|
||||||
|
"display-title": "Sohl Patrol LIVE",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
moved = await songs_for(other)
|
||||||
|
assert [song.title for song in moved] == ["Sohl Patrol", "Night Shift Over"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_describes_its_catalogue() -> None:
|
||||||
|
provider = BassdriveProvider()
|
||||||
|
assert provider.has_schedule is True
|
||||||
|
assert provider.bandcamp is False
|
||||||
|
assert len(provider.hero) == 3
|
||||||
|
assert "bright_cyan" in "".join(provider.hero)
|
||||||
|
assert provider.hero_lines == (
|
||||||
|
"BASSDRIVE",
|
||||||
|
"WORLDWIDE DRUM & BASS",
|
||||||
|
"LIVE SHOWS 24/7",
|
||||||
|
)
|
||||||
|
|
||||||
|
stations = provider.fallback_stations()
|
||||||
|
assert [station.id for station in stations] == ["bassdrive"]
|
||||||
|
urls = provider.fallback_urls(stations[0])
|
||||||
|
assert urls[0].startswith("https://") and len(urls) == 7
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cold_start_survives_an_unreachable_homepage() -> None:
|
||||||
|
"""`fetch_station` asks for the grid; a broken homepage must not stop it."""
|
||||||
|
async with client_for(STATUS, page_code=500) as client:
|
||||||
|
station = await api.fetch_station(client)
|
||||||
|
assert station.id == "bassdrive"
|
||||||
|
assert station.presenter == "live now" # live, but the location is unknown
|
||||||
|
assert station.now_playing is not None
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""The community-station providers: relays, status documents and schedules."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from beefy_player.providers import PROVIDERS
|
||||||
|
from beefy_player.providers.community import (
|
||||||
|
AnonradioProvider,
|
||||||
|
CashmereProvider,
|
||||||
|
DatafruitsProvider,
|
||||||
|
DublabProvider,
|
||||||
|
LotProvider,
|
||||||
|
LylProvider,
|
||||||
|
RefugeProvider,
|
||||||
|
RinseProvider,
|
||||||
|
dublab,
|
||||||
|
)
|
||||||
|
|
||||||
|
ICECAST = {
|
||||||
|
"icestats": {
|
||||||
|
"source": [
|
||||||
|
{
|
||||||
|
"listenurl": "https://anonradio.net:8443/anonradio",
|
||||||
|
"bitrate": 192,
|
||||||
|
"server_type": "audio/mpeg",
|
||||||
|
"listeners": 13,
|
||||||
|
"title": "repeating screwtape from 29-Nov-2023",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RADIO_CO = {
|
||||||
|
"status": "online",
|
||||||
|
"listeners": 41,
|
||||||
|
"current_track": {
|
||||||
|
"title": "Guest Show (r) - Ain Autavg",
|
||||||
|
"start_time": "2026-11-04T00:00:02+00:00",
|
||||||
|
"artwork_url": "https://images.radio.co/station.png",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def client_for(payload, status_code: int = 200) -> httpx.AsyncClient:
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(status_code, json=payload)
|
||||||
|
|
||||||
|
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_community_provider_builds_a_playable_station() -> None:
|
||||||
|
providers = (
|
||||||
|
AnonradioProvider,
|
||||||
|
DatafruitsProvider,
|
||||||
|
CashmereProvider,
|
||||||
|
LylProvider,
|
||||||
|
LotProvider,
|
||||||
|
RefugeProvider,
|
||||||
|
DublabProvider,
|
||||||
|
RinseProvider,
|
||||||
|
)
|
||||||
|
for cls in providers:
|
||||||
|
provider = cls()
|
||||||
|
stations = provider.fallback_stations()
|
||||||
|
assert stations, cls.id
|
||||||
|
for station in stations:
|
||||||
|
assert station.provider == provider.id
|
||||||
|
assert station.playlists, (cls.id, station.id)
|
||||||
|
assert all(url.startswith(("http://", "https://")) for url in provider.fallback_urls(station))
|
||||||
|
# every one of them spells its name in the same font
|
||||||
|
assert len(provider.hero) == 3, cls.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_rinse_is_a_family_of_three_stations() -> None:
|
||||||
|
stations = RinseProvider().fallback_stations()
|
||||||
|
assert [station.id for station in stations] == ["rinse_uk", "kool", "swu"]
|
||||||
|
assert [station.title for station in stations] == ["Rinse UK", "Kool London", "SWU"]
|
||||||
|
for station in stations:
|
||||||
|
assert "admin.stream.rinse.fm/proxy/" in station.playlists[0]["url"]
|
||||||
|
assert station.format_label.endswith("128k")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_lot_streams_hls_which_the_player_owns() -> None:
|
||||||
|
from beefy_player.providers.base import is_direct_stream, is_playlist
|
||||||
|
|
||||||
|
url = LotProvider().fallback_urls(LotProvider().station())[0]
|
||||||
|
assert url.endswith(".m3u8")
|
||||||
|
assert is_playlist(url) is False
|
||||||
|
assert is_direct_stream(url) is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_icecast_status_gives_the_station_what_is_on() -> None:
|
||||||
|
provider = AnonradioProvider()
|
||||||
|
async with client_for(ICECAST) as client:
|
||||||
|
station = (await provider.fetch_stations(client))[0]
|
||||||
|
assert station.listeners == 13
|
||||||
|
assert station.now_playing is not None
|
||||||
|
assert station.now_playing.title == "repeating screwtape from 29-Nov-2023"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_radio_co_status_gives_a_track_and_its_art() -> None:
|
||||||
|
provider = RefugeProvider()
|
||||||
|
async with client_for(RADIO_CO) as client:
|
||||||
|
station = (await provider.fetch_stations(client))[0]
|
||||||
|
history = await provider.fetch_history(client, "refuge")
|
||||||
|
assert station.listeners == 41
|
||||||
|
assert station.now_playing is not None
|
||||||
|
assert station.now_playing.album == ""
|
||||||
|
assert station.now_playing.art_url.endswith("station.png")
|
||||||
|
assert history[0].title == "Ain Autavg"
|
||||||
|
# the show's own start time is used, not "just now"
|
||||||
|
assert history[0].played_at == datetime(
|
||||||
|
2026, 11, 4, 0, 0, 2, tzinfo=timezone.utc
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_history_is_rolling_and_keeps_the_first_sighting() -> None:
|
||||||
|
provider = AnonradioProvider()
|
||||||
|
async with client_for(ICECAST) as client:
|
||||||
|
first = await provider.fetch_history(client, "anonradio")
|
||||||
|
again = await provider.fetch_history(client, "anonradio")
|
||||||
|
assert len(first) == 1
|
||||||
|
assert again[0].played_at == first[0].played_at
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_station_without_metadata_still_carries_on() -> None:
|
||||||
|
"""Rinse, LYL, The Lot and Cashmere publish no status document."""
|
||||||
|
provider = RinseProvider()
|
||||||
|
async with client_for({}, status_code=500) as client:
|
||||||
|
stations = await provider.fetch_stations(client)
|
||||||
|
history = await provider.fetch_history(client, "rinse_uk")
|
||||||
|
assert len(stations) == 3
|
||||||
|
assert all(station.now_playing is None for station in stations)
|
||||||
|
assert history == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_dublab_schedule_parses_the_site_calendar() -> None:
|
||||||
|
payload = [
|
||||||
|
{
|
||||||
|
"startTime": "2026-11-03T16:00:00-08:00",
|
||||||
|
"endTime": "2026-11-03T18:00:00-08:00",
|
||||||
|
"summary": "LIVE // Version Sounds",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"startTime": "2026-11-03T18:00:00-08:00",
|
||||||
|
"endTime": "2026-11-03T20:00:00-08:00",
|
||||||
|
"summary": "PRE-REC // Sounds of Now",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
shows = dublab.parse_schedule(payload)
|
||||||
|
assert [show.name for show in shows] == [
|
||||||
|
"LIVE // Version Sounds",
|
||||||
|
"PRE-REC // Sounds of Now",
|
||||||
|
]
|
||||||
|
assert shows[0].start == datetime(
|
||||||
|
2026, 11, 3, 16, tzinfo=timezone(timedelta(hours=-8))
|
||||||
|
)
|
||||||
|
assert dublab.parse_schedule({"nope": True}) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_running_slot_is_marked_live() -> None:
|
||||||
|
provider = DublabProvider()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
running = dublab.Broadcast(
|
||||||
|
name="LIVE // Version Sounds",
|
||||||
|
start=now - timedelta(minutes=30),
|
||||||
|
end=now + timedelta(minutes=30),
|
||||||
|
)
|
||||||
|
later = dublab.Broadcast(
|
||||||
|
name="Later Show",
|
||||||
|
start=now + timedelta(hours=1),
|
||||||
|
end=now + timedelta(hours=2),
|
||||||
|
)
|
||||||
|
marked = provider._mark_live([running, later])
|
||||||
|
assert [item.live for item in marked] == [True, False]
|
||||||
|
assert marked[0].key() == running.key()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dublab_history_follows_the_schedule() -> None:
|
||||||
|
provider = DublabProvider()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
payload = [
|
||||||
|
{
|
||||||
|
"startTime": (now - timedelta(minutes=20)).isoformat(),
|
||||||
|
"endTime": (now + timedelta(minutes=40)).isoformat(),
|
||||||
|
"summary": "LIVE // Version Sounds",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json=payload)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
await provider.fetch_schedule(client)
|
||||||
|
history = await provider.fetch_history(client, "dublab")
|
||||||
|
assert history and history[0].title == "LIVE // Version Sounds"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_catalogue_is_registered_under_its_slug() -> None:
|
||||||
|
assert set(PROVIDERS) == {
|
||||||
|
"somafm",
|
||||||
|
"tilderadio",
|
||||||
|
"bassdrive",
|
||||||
|
"radioparadise",
|
||||||
|
"dublab",
|
||||||
|
"rinse",
|
||||||
|
"refuge",
|
||||||
|
"lyl",
|
||||||
|
"lot",
|
||||||
|
"radiobrowser",
|
||||||
|
"cashmere",
|
||||||
|
"datafruits",
|
||||||
|
"anonradio",
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""The BBS frames: grey edges, and a short palette glow in two corners."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
from beefy_player.palettes import PALETTES, brightness
|
||||||
|
from beefy_player.widgets import frame
|
||||||
|
|
||||||
|
#: The original ladder, used as a fixed reference in these tests.
|
||||||
|
STOPS = PALETTES["spectrum"]
|
||||||
|
#: Its glow: the brightest cell first, dimmer cells towards the edge.
|
||||||
|
RAMP = frame.fade_ramp(STOPS)
|
||||||
|
HOT = RAMP[0] # the corner colour
|
||||||
|
|
||||||
|
|
||||||
|
def styles_of(line: Text) -> list[tuple[str, str]]:
|
||||||
|
return [
|
||||||
|
(line.plain[span.start : span.end], str(span.style)) for span in line.spans
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_corner_gets_the_brightest_colour_of_the_fade() -> None:
|
||||||
|
ranks = [brightness(colour) for colour in RAMP]
|
||||||
|
assert ranks == sorted(ranks, reverse=True)
|
||||||
|
# spectrum's hot end is red, which is dimmer than the yellow before it:
|
||||||
|
# the corner must still be the bright one, not the ladder's last stop
|
||||||
|
assert STOPS[-1] != HOT
|
||||||
|
assert brightness(HOT) > brightness(STOPS[-1])
|
||||||
|
assert set(RAMP) <= set(STOPS)
|
||||||
|
assert frame.corner_fade(len(RAMP), STOPS) == frame.EDGE
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_the_top_right_corner_fades_on_the_top_edge() -> None:
|
||||||
|
spans = styles_of(frame.top(30, stops=STOPS))
|
||||||
|
assert spans[0] == ("╭", frame.EDGE) # plain grey corner
|
||||||
|
# the fade runs into the top-right corner, brightest cell last
|
||||||
|
assert [style for _text, style in spans[-4:-1]] == [RAMP[3], RAMP[2], RAMP[1]]
|
||||||
|
assert spans[-1] == ("╮", f"bold {HOT}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_the_bottom_left_corner_fades_on_the_bottom_edge() -> None:
|
||||||
|
spans = styles_of(frame.bottom(30, STOPS))
|
||||||
|
assert spans[0] == ("╰", f"bold {HOT}")
|
||||||
|
assert [style for _text, style in spans[1:4]] == [RAMP[1], RAMP[2], RAMP[3]]
|
||||||
|
assert spans[-1] == ("╯", frame.EDGE) # plain grey corner
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_sides_glow_towards_their_own_corner_only() -> None:
|
||||||
|
# left column fades up from the bottom-left corner
|
||||||
|
assert [frame.side_style(row, 10, "left", STOPS) for row in range(10)] == [
|
||||||
|
frame.EDGE,
|
||||||
|
frame.EDGE,
|
||||||
|
frame.EDGE,
|
||||||
|
frame.EDGE,
|
||||||
|
frame.EDGE,
|
||||||
|
frame.EDGE,
|
||||||
|
frame.EDGE,
|
||||||
|
RAMP[3],
|
||||||
|
RAMP[2],
|
||||||
|
RAMP[1],
|
||||||
|
]
|
||||||
|
# right column fades down from the top-right corner
|
||||||
|
assert [frame.side_style(row, 10, "right", STOPS) for row in range(10)] == [
|
||||||
|
RAMP[1],
|
||||||
|
RAMP[2],
|
||||||
|
RAMP[3],
|
||||||
|
frame.EDGE,
|
||||||
|
frame.EDGE,
|
||||||
|
frame.EDGE,
|
||||||
|
frame.EDGE,
|
||||||
|
frame.EDGE,
|
||||||
|
frame.EDGE,
|
||||||
|
frame.EDGE,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_title_sits_after_the_left_fade() -> None:
|
||||||
|
line = frame.top(30, "STATIONS", STOPS)
|
||||||
|
assert line.plain.startswith("╭─ STATIONS ─")
|
||||||
|
assert "STATIONS" in line.plain
|
||||||
|
# a pane too narrow for the title drops it rather than truncating
|
||||||
|
assert "STATIONS" not in frame.top(10, "STATIONS", STOPS).plain
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrap_frames_the_content_on_every_row() -> None:
|
||||||
|
content = [Text("hello"), Text("world")]
|
||||||
|
group = frame.wrap(content, 20, 6, "PANE", STOPS)
|
||||||
|
lines = [line.plain for line in group.renderables]
|
||||||
|
assert len(lines) == 6
|
||||||
|
assert lines[0].startswith("╭") and lines[-1].startswith("╰")
|
||||||
|
assert all(len(line) == 20 for line in lines)
|
||||||
|
assert all(line.startswith("│") and line.endswith("│") for line in lines[1:-1])
|
||||||
|
assert lines[1].startswith("│hello")
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_wider_than_the_pane_is_trimmed_not_spilled() -> None:
|
||||||
|
"""A pane must never draw over its neighbour."""
|
||||||
|
long_line = Text("x" * 40)
|
||||||
|
group = frame.wrap([long_line], 20, 4, "PANE", STOPS)
|
||||||
|
lines = [line.plain for line in group.renderables]
|
||||||
|
assert all(len(line) == 20 for line in lines)
|
||||||
|
assert lines[1].endswith("│")
|
||||||
|
# the original renderable is left untouched
|
||||||
|
assert long_line.plain == "x" * 40
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_short_pane_still_glows_in_its_bottom_left_corner() -> None:
|
||||||
|
"""The fade follows the pane's height, not the number of content rows."""
|
||||||
|
group = frame.wrap([Text("one line")], 20, 6, stops=STOPS)
|
||||||
|
lines = list(group.renderables)
|
||||||
|
# rows 1-4 are the content box; the fade climbs its last three rows
|
||||||
|
assert str(lines[1].spans[0].style) == frame.EDGE
|
||||||
|
assert [str(line.spans[0].style) for line in lines[2:5]] == [
|
||||||
|
RAMP[3],
|
||||||
|
RAMP[2],
|
||||||
|
RAMP[1],
|
||||||
|
]
|
||||||
+103
-5
@@ -6,18 +6,27 @@ 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.providers import PROVIDERS
|
||||||
|
from beefy_player.providers.somafm import SomaFMProvider
|
||||||
|
from beefy_player.providers.wordmark import render
|
||||||
|
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}$")
|
||||||
VOLUME = re.compile(r"vol \d+%$")
|
VOLUME = re.compile(r"vol \d+%$")
|
||||||
|
|
||||||
|
|
||||||
def render_header(width: int, height: int = 3) -> list[str]:
|
def render_header(
|
||||||
|
width: int, height: int = 3, provider=None
|
||||||
|
) -> list[str]:
|
||||||
"""Render the header without a running app, as plain text per line."""
|
"""Render the header without a running app, as plain text per line."""
|
||||||
fake = type("Fake", (), {})()
|
fake = type("Fake", (), {})()
|
||||||
fake.app = types.SimpleNamespace(
|
fake.app = types.SimpleNamespace(
|
||||||
playing_state="playing", volume=70, current=None, notice=None
|
playing_state="playing",
|
||||||
|
volume=70,
|
||||||
|
current=None,
|
||||||
|
notice=None,
|
||||||
|
provider=provider or SomaFMProvider(),
|
||||||
)
|
)
|
||||||
fake.size = types.SimpleNamespace(width=width, height=height)
|
fake.size = types.SimpleNamespace(width=width, height=height)
|
||||||
return [line.plain.rstrip() for line in HeaderBar.render(fake).renderables]
|
return [line.plain.rstrip() for line in HeaderBar.render(fake).renderables]
|
||||||
@@ -34,8 +43,8 @@ def test_right_block_is_flush_right(width: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_ragged_middle_does_not_shift_the_right_block() -> None:
|
def test_ragged_middle_does_not_shift_the_right_block() -> None:
|
||||||
""""SOMA FM RADIO" and "SAN FRANCISCO" are shorter than
|
""""TILDERADIO" is shorter than "STREAMED BY / FOR USERS OF THE"; the
|
||||||
"LISTENER SUPPORTED"; the right block must not follow their length."""
|
right block must not follow the length of the middle lines."""
|
||||||
clock, date, volume = render_header(143)
|
clock, date, volume = render_header(143)
|
||||||
assert cell_len(clock) == cell_len(date) == cell_len(volume) == 143
|
assert cell_len(clock) == cell_len(date) == cell_len(volume) == 143
|
||||||
|
|
||||||
@@ -44,3 +53,92 @@ def test_right_block_hidden_on_narrow_terminals() -> None:
|
|||||||
"""Below the threshold the right block is dropped, not squashed."""
|
"""Below the threshold the right block is dropped, not squashed."""
|
||||||
lines = render_header(30)
|
lines = render_header(30)
|
||||||
assert all(":" not in line for line in lines)
|
assert all(":" not in line for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_somafm_hero_spells_the_whole_name_and_its_taglines() -> None:
|
||||||
|
lines = render_header(143)
|
||||||
|
assert lines[0].startswith(render("SOMAFM")[0])
|
||||||
|
assert "SOMA FM RADIO" in lines[0]
|
||||||
|
assert "LISTENER SUPPORTED" in lines[1]
|
||||||
|
assert "SAN FRANCISCO" in lines[2]
|
||||||
|
|
||||||
|
|
||||||
|
def hero_styles(provider) -> list[str]:
|
||||||
|
return [
|
||||||
|
str(span.style)
|
||||||
|
for line in HeaderBar.render(_fake(provider)).renderables
|
||||||
|
for span in line.spans
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_i_and_t_have_thin_stems() -> None:
|
||||||
|
"""Two-pixel stems make an I look like an H and a T look like a block."""
|
||||||
|
assert render("I") == ("▀█▀", " █", "▄█▄")
|
||||||
|
assert render("T") == ("▀█▀", " █", " █")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_font_still_spells_soma_the_way_the_original_logo_did() -> None:
|
||||||
|
"""The wordmark font is the SomaFM logo, so the old art must be reproduced."""
|
||||||
|
assert render("SOMA") == (
|
||||||
|
"▄▀▀▀ ▄▀▀▄ █▄ ▄█ ▄▀▀▄",
|
||||||
|
"▀▀▀▄ █ █ █ █ █ █▀▀█",
|
||||||
|
"▄▄▄▀ ▀▄▄▀ █ █ █ █",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("slug", sorted(PROVIDERS))
|
||||||
|
def test_the_caption_block_starts_on_one_column(slug: str) -> None:
|
||||||
|
"""Art rows are right-trimmed; a row ending in a blank cell must not pull
|
||||||
|
its caption a column to the left."""
|
||||||
|
provider = PROVIDERS[slug]
|
||||||
|
captions = provider.hero_lines
|
||||||
|
offset = max(0, (len(provider.hero) - len(captions)) // 2)
|
||||||
|
lines = render_header(200, height=len(provider.hero), provider=provider)
|
||||||
|
lines = lines[offset : offset + len(captions)]
|
||||||
|
columns = {
|
||||||
|
line.index(caption)
|
||||||
|
for line, caption in zip(lines, captions, strict=True)
|
||||||
|
}
|
||||||
|
assert len(columns) == 1, (slug, columns)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_catalogue_has_a_three_row_wordmark() -> None:
|
||||||
|
for slug, provider in PROVIDERS.items():
|
||||||
|
assert len(provider.hero) == 3, slug
|
||||||
|
assert provider.hero_lines, slug
|
||||||
|
|
||||||
|
|
||||||
|
def test_tilderadio_spells_its_own_name_in_the_default_colour() -> None:
|
||||||
|
provider = PROVIDERS["tilderadio"]
|
||||||
|
lines = render_header(143, height=len(provider.hero), provider=provider)
|
||||||
|
assert len(lines) == 3
|
||||||
|
assert "TILDERADIO" in lines[0] # the wordmark
|
||||||
|
assert "STREAMED BY / FOR USERS OF THE" in lines[1]
|
||||||
|
assert "TILDEVERSE" in lines[2]
|
||||||
|
# no colour of its own: the art inherits the terminal foreground
|
||||||
|
assert all("[" not in row for row in provider.hero)
|
||||||
|
assert not {"bright_cyan", "cyan"} & set(hero_styles(provider))
|
||||||
|
assert all("SOMA" not in line for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bassdrive_spells_its_name_in_its_own_two_blues() -> None:
|
||||||
|
provider = PROVIDERS["bassdrive"]
|
||||||
|
lines = render_header(143, height=len(provider.hero), provider=provider)
|
||||||
|
assert "BASSDRIVE" in lines[0]
|
||||||
|
assert "WORLDWIDE DRUM & BASS" in lines[1]
|
||||||
|
assert "LIVE SHOWS 24/7" in lines[2]
|
||||||
|
styles = hero_styles(provider)
|
||||||
|
assert "bright_cyan" in styles and "cyan" in styles
|
||||||
|
|
||||||
|
|
||||||
|
def _fake(provider):
|
||||||
|
fake = type("Fake", (), {})()
|
||||||
|
fake.app = types.SimpleNamespace(
|
||||||
|
playing_state="playing",
|
||||||
|
volume=70,
|
||||||
|
current=None,
|
||||||
|
notice=None,
|
||||||
|
provider=provider,
|
||||||
|
)
|
||||||
|
fake.size = types.SimpleNamespace(width=143, height=len(provider.hero) or 3)
|
||||||
|
return fake
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
"""The colour palettes: sixteen ladders of ANSI names, and the `c` key."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from beefy_player import config
|
||||||
|
from beefy_player.app import BeefyPlayerApp
|
||||||
|
from beefy_player.palettes import (
|
||||||
|
DEFAULT_PALETTE,
|
||||||
|
PALETTE_ORDER,
|
||||||
|
PALETTES,
|
||||||
|
brightness,
|
||||||
|
palette_stops,
|
||||||
|
)
|
||||||
|
from beefy_player.widgets import frame
|
||||||
|
from beefy_player.widgets.help import HelpScreen
|
||||||
|
from beefy_player.widgets.station_list import StationList
|
||||||
|
|
||||||
|
ANSI_NAMES = {
|
||||||
|
"black",
|
||||||
|
"red",
|
||||||
|
"green",
|
||||||
|
"yellow",
|
||||||
|
"blue",
|
||||||
|
"magenta",
|
||||||
|
"cyan",
|
||||||
|
"white",
|
||||||
|
"bright_black",
|
||||||
|
"bright_red",
|
||||||
|
"bright_green",
|
||||||
|
"bright_yellow",
|
||||||
|
"bright_blue",
|
||||||
|
"bright_magenta",
|
||||||
|
"bright_cyan",
|
||||||
|
"bright_white",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_there_are_sixteen_palettes_of_ansi_names() -> None:
|
||||||
|
assert len(PALETTES) == 16
|
||||||
|
assert len(PALETTE_ORDER) == 16
|
||||||
|
assert len(set(PALETTE_ORDER)) == 16
|
||||||
|
assert DEFAULT_PALETTE in PALETTES
|
||||||
|
|
||||||
|
for name, stops in PALETTES.items():
|
||||||
|
assert len(stops) >= 3, name
|
||||||
|
assert set(stops) <= ANSI_NAMES, (name, set(stops) - ANSI_NAMES)
|
||||||
|
# a ladder, not a list: the quiet end must differ from the hot end
|
||||||
|
assert stops[0] != stops[-1], name
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_broken_palette_name_falls_back_to_the_default() -> None:
|
||||||
|
assert palette_stops("mono") == PALETTES["mono"]
|
||||||
|
assert palette_stops(" MONO ") == PALETTES["mono"]
|
||||||
|
assert palette_stops("no-such-palette") == PALETTES[DEFAULT_PALETTE]
|
||||||
|
assert palette_stops(None) == PALETTES[DEFAULT_PALETTE]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_config_remembers_the_palette() -> None:
|
||||||
|
assert config.Config.load().palette == DEFAULT_PALETTE
|
||||||
|
config.Config(palette="ember").save()
|
||||||
|
assert config.Config.load().palette == "ember"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unknown_palette_in_the_config_is_ignored() -> None:
|
||||||
|
config.CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
config.CONFIG_PATH.write_text('{"palette": "chartreuse"}')
|
||||||
|
assert config.Config.load().palette == DEFAULT_PALETTE
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_c_key_cycles_and_remembers_the_palette() -> None:
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(100, 30)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
start = app.palette
|
||||||
|
assert start == DEFAULT_PALETTE
|
||||||
|
|
||||||
|
await pilot.press("c")
|
||||||
|
await pilot.pause()
|
||||||
|
expected = PALETTE_ORDER[(PALETTE_ORDER.index(start) + 1) % len(PALETTE_ORDER)]
|
||||||
|
assert app.palette == expected
|
||||||
|
assert app.config.palette == expected
|
||||||
|
assert "palette" in app.notice
|
||||||
|
|
||||||
|
# the choice survives a restart
|
||||||
|
config.Config(palette=app.palette).save()
|
||||||
|
assert config.Config.load().palette == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_pane_corners_follow_the_palette() -> None:
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(100, 30)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
pane = app.query_one(StationList)
|
||||||
|
|
||||||
|
app.palette = "mono"
|
||||||
|
mono = [str(span.style) for span in pane.render().renderables[0].spans]
|
||||||
|
app.palette = "ember"
|
||||||
|
ember = [str(span.style) for span in pane.render().renderables[0].spans]
|
||||||
|
|
||||||
|
assert mono != ember
|
||||||
|
assert any("bright_white" in style for style in mono)
|
||||||
|
# ember's hot end is white too, but its quiet end is not
|
||||||
|
assert any(PALETTES["ember"][-2] in style for style in ember)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_spectrum_rows_use_the_active_palette() -> None:
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(100, 30)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
spectrum = app.query_one("SpectrumPanel")
|
||||||
|
|
||||||
|
def colours(styles: list[str]) -> set[str]:
|
||||||
|
return {
|
||||||
|
style.replace("dim ", "").replace("bold ", "").strip()
|
||||||
|
for style in styles
|
||||||
|
}
|
||||||
|
|
||||||
|
app.palette = "mono"
|
||||||
|
spectrum.refresh()
|
||||||
|
await pilot.pause()
|
||||||
|
assert colours(spectrum._row_palette(12, 0, False)) <= set(PALETTES["mono"])
|
||||||
|
|
||||||
|
app.palette = "neon"
|
||||||
|
spectrum.refresh()
|
||||||
|
await pilot.pause()
|
||||||
|
assert colours(spectrum._row_palette(12, 0, False)) <= set(PALETTES["neon"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_corner_fade_starts_at_the_brightest_stop() -> None:
|
||||||
|
stops = PALETTES["spectrum"]
|
||||||
|
ramp = frame.fade_ramp(stops)
|
||||||
|
assert frame.corner_fade(0, stops) == ramp[0]
|
||||||
|
assert frame.corner_fade(1, stops) == ramp[1]
|
||||||
|
# the fade is short: past the ramp it is just the border grey
|
||||||
|
assert frame.corner_fade(len(ramp), stops) == frame.EDGE
|
||||||
|
# spectrum's hot end is red, which is dimmer than the yellow before it;
|
||||||
|
# the corner still gets the bright colour
|
||||||
|
assert ramp[0] != stops[-1]
|
||||||
|
assert ramp[-1] == stops[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_palette_fades_from_bright_to_dim() -> None:
|
||||||
|
"""No palette may put a brighter cell further from the corner."""
|
||||||
|
for name, stops in PALETTES.items():
|
||||||
|
ramp = frame.fade_ramp(stops)
|
||||||
|
assert ramp, name
|
||||||
|
ranks = [brightness(colour) for colour in ramp]
|
||||||
|
assert ranks == sorted(ranks, reverse=True), (name, ramp)
|
||||||
|
assert set(ramp) <= set(stops), name
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_help_scrolls_when_the_terminal_is_short() -> None:
|
||||||
|
"""The help page is ~56 rows: it must be readable on a 24-row screen."""
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(80, 24)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("question_mark")
|
||||||
|
await pilot.pause()
|
||||||
|
body = app.screen.query_one("#help")
|
||||||
|
assert body.size.height < body.virtual_size.height
|
||||||
|
assert body.max_scroll_y > 0
|
||||||
|
assert body.scroll_y == 0
|
||||||
|
|
||||||
|
await pilot.press("pagedown")
|
||||||
|
await pilot.pause()
|
||||||
|
assert body.scroll_y > 0
|
||||||
|
|
||||||
|
await pilot.press("end")
|
||||||
|
await pilot.pause()
|
||||||
|
assert body.scroll_y == body.max_scroll_y
|
||||||
|
|
||||||
|
await pilot.press("home")
|
||||||
|
await pilot.pause()
|
||||||
|
assert body.scroll_y == 0
|
||||||
|
|
||||||
|
await pilot.press("x")
|
||||||
|
await pilot.pause()
|
||||||
|
assert not isinstance(app.screen, HelpScreen)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_tall_terminal_needs_no_scrolling() -> None:
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(120, 70)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("question_mark")
|
||||||
|
await pilot.pause()
|
||||||
|
body = app.screen.query_one("#help")
|
||||||
|
assert body.max_scroll_y == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_help_lists_every_palette_and_marks_the_active_one() -> None:
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(120, 64)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
app.palette = "ocean"
|
||||||
|
await pilot.press("question_mark")
|
||||||
|
await pilot.pause()
|
||||||
|
text = html.unescape(app.export_screenshot()).replace("\xa0", " ")
|
||||||
|
text = re.sub(r"<[^>]+>", "", text)
|
||||||
|
|
||||||
|
assert "PALETTES" in text
|
||||||
|
assert "c cycles them" in text
|
||||||
|
for name in PALETTE_ORDER:
|
||||||
|
assert name in text, name
|
||||||
|
assert "● ocean" in text
|
||||||
|
# each palette shows a swatch of eight blocks
|
||||||
|
assert "████████" in text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_help_footer_attributes_the_build() -> None:
|
||||||
|
"""One line: the version and the project URL — no credits essay."""
|
||||||
|
from beefy_player import __version__
|
||||||
|
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(110, 70)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("question_mark")
|
||||||
|
await pilot.pause()
|
||||||
|
body = app.screen.query_one("#help")
|
||||||
|
body.scroll_end(animate=False)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
text = html.unescape(app.export_screenshot()).replace("\xa0", " ")
|
||||||
|
text = re.sub(r"<[^>]+>", "", text)
|
||||||
|
|
||||||
|
assert f"beefy player {__version__}" in text
|
||||||
|
assert "https://tildeforge.dev/beefcarver/beefy-player" in text
|
||||||
|
# the old explanatory blurb is gone
|
||||||
|
assert "playerctl" not in text
|
||||||
|
assert "parec" not in text
|
||||||
|
assert "Stations and song history come from" not in text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("slug", ["bassdrive", "somafm"])
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_help_has_no_pane_explainer(slug: str) -> None:
|
||||||
|
"""No catalogue gets a paragraph about its left pane."""
|
||||||
|
app = BeefyPlayerApp(provider=slug, autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(110, 60)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("question_mark")
|
||||||
|
await pilot.pause()
|
||||||
|
text = html.unescape(app.export_screenshot()).replace("\xa0", " ")
|
||||||
|
text = re.sub(r"<[^>]+>", "", text)
|
||||||
|
assert "is a single stream" not in text
|
||||||
|
assert "multi-station catalogues" not in text
|
||||||
|
assert "CATALOGUES" in text
|
||||||
+13
-13
@@ -2,13 +2,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
|
import sys
|
||||||
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 = f"#!{sys.executable}\n" + '''import json, os, socket, sys, time
|
||||||
import json, os, socket, sys, time
|
|
||||||
|
|
||||||
sock_path = None
|
sock_path = None
|
||||||
volume = 70
|
volume = 70
|
||||||
@@ -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,115 @@
|
|||||||
|
"""The ON AIR metadata line: who is on the desk, and no lone artwork glyph."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import types
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from beefy_player.app import BeefyPlayerApp
|
||||||
|
from beefy_player.models import Song, Station
|
||||||
|
from beefy_player.providers.somafm import SomaFMProvider
|
||||||
|
from beefy_player.providers.tilderadio import TilderadioProvider, api
|
||||||
|
from beefy_player.widgets.stage import StageInfo
|
||||||
|
|
||||||
|
|
||||||
|
def details(station: Station, song: Song | None, width: int = 90) -> list[str]:
|
||||||
|
app = types.SimpleNamespace(playing_state="playing", notice="")
|
||||||
|
return [line.plain for line in StageInfo()._details(app, station, song, width)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_somafm_still_prefixes_a_dj_name() -> None:
|
||||||
|
station = Station(id="gs", title="Groove Salad", dj="SomaFM", provider="somafm")
|
||||||
|
lines = details(station, Song(artist="A", title="T", album="Album"))
|
||||||
|
assert any("Album" in line and "dj SomaFM" in line for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lone_artwork_glyph_is_not_drawn() -> None:
|
||||||
|
"""No album, no presenter: the ▣ would be a box with nothing to mark."""
|
||||||
|
station = Station(id="x", title="X", provider="somafm")
|
||||||
|
lines = details(station, Song(artist="A", title="T", art_url="https://a/1.jpg"))
|
||||||
|
assert not any("▣" in line for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_artwork_glyph_rides_along_with_real_information() -> None:
|
||||||
|
station = Station(id="x", title="X", dj="Someone", provider="somafm")
|
||||||
|
lines = details(station, Song(artist="A", title="T", art_url="https://a/1.jpg"))
|
||||||
|
assert any("dj Someone" in line and "▣" in line for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tilderadio_shows_the_rotation_when_nothing_is_live() -> None:
|
||||||
|
payload = {
|
||||||
|
"station": {"name": "tilderadio", "shortcode": "tilderadio"},
|
||||||
|
"is_live": False,
|
||||||
|
}
|
||||||
|
station = api.station_from_now(payload)
|
||||||
|
assert station is not None
|
||||||
|
assert station.presenter_label == "auto dj · library rotation"
|
||||||
|
lines = details(station, Song(artist="A", title="T", art_url="https://a/1.jpg"))
|
||||||
|
assert any("auto dj · library rotation" in line for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tilderadio_shows_the_live_dj_instead() -> None:
|
||||||
|
payload = {
|
||||||
|
"station": {"name": "tilderadio", "shortcode": "tilderadio"},
|
||||||
|
"is_live": True,
|
||||||
|
"dj": "dokuja",
|
||||||
|
}
|
||||||
|
station = api.station_from_now(payload)
|
||||||
|
assert station is not None
|
||||||
|
assert station.presenter_label == "dj dokuja"
|
||||||
|
assert "auto dj" not in station.presenter_label
|
||||||
|
|
||||||
|
|
||||||
|
def test_presenter_label_falls_back_to_the_dj_then_nothing() -> None:
|
||||||
|
assert Station(id="a", title="A").presenter_label == ""
|
||||||
|
assert Station(id="a", title="A", dj="kexp").presenter_label == "dj kexp"
|
||||||
|
assert (
|
||||||
|
Station(id="a", title="A", dj="kexp", presenter="guest host").presenter_label
|
||||||
|
== "guest host"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tilderadio_opts_out_of_bandcamp_lookups() -> None:
|
||||||
|
assert TilderadioProvider().bandcamp is False
|
||||||
|
assert SomaFMProvider().bandcamp is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_bandcamp_worker_is_started_for_tilderadio(monkeypatch) -> None:
|
||||||
|
calls: list[tuple] = []
|
||||||
|
|
||||||
|
async def fake_find(client, artist, album, title):
|
||||||
|
calls.append((artist, album, title))
|
||||||
|
return "https://bandcamp.test/track"
|
||||||
|
|
||||||
|
monkeypatch.setattr("beefy_player.bandcamp.find_track", fake_find)
|
||||||
|
|
||||||
|
app = BeefyPlayerApp(
|
||||||
|
provider="tilderadio", autoplay=False, offline=True, audio=False
|
||||||
|
)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
station = app.current
|
||||||
|
assert station is not None
|
||||||
|
station.now_playing = Song(artist="A", title="T")
|
||||||
|
app._update_bandcamp()
|
||||||
|
await pilot.pause()
|
||||||
|
assert calls == []
|
||||||
|
assert station.now_playing.bandcamp_url == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_stale_bandcamp_link_is_cleared_when_the_catalogue_does_not_want_one() -> None:
|
||||||
|
app = BeefyPlayerApp(
|
||||||
|
provider="tilderadio", autoplay=False, offline=True, audio=False
|
||||||
|
)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
station = app.current
|
||||||
|
assert station is not None
|
||||||
|
station.now_playing = Song(
|
||||||
|
artist="A", title="T", bandcamp_url="https://bandcamp.test/old"
|
||||||
|
)
|
||||||
|
app._update_bandcamp()
|
||||||
|
assert station.now_playing.bandcamp_url == ""
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
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.bassdrive import BassdriveProvider
|
||||||
|
from beefy_player.providers.somafm import SomaFMProvider
|
||||||
|
from beefy_player.providers.tilderadio import TilderadioProvider
|
||||||
|
from beefy_player.widgets.header import HeaderBar
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
# the menu is alphabetical by name: aNONradio ... tilderadio
|
||||||
|
assert available_providers() == (
|
||||||
|
"anonradio",
|
||||||
|
"bassdrive",
|
||||||
|
"cashmere",
|
||||||
|
"datafruits",
|
||||||
|
"dublab",
|
||||||
|
"lyl",
|
||||||
|
"radiobrowser",
|
||||||
|
"radioparadise",
|
||||||
|
"refuge",
|
||||||
|
"rinse",
|
||||||
|
"somafm",
|
||||||
|
"lot",
|
||||||
|
"tilderadio",
|
||||||
|
)
|
||||||
|
assert isinstance(get_provider(), SomaFMProvider)
|
||||||
|
assert get_provider("somafm") is get_provider()
|
||||||
|
assert isinstance(get_provider("tilderadio"), TilderadioProvider)
|
||||||
|
assert isinstance(get_provider("bassdrive"), BassdriveProvider)
|
||||||
|
# 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") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_tilderadio_replaces_the_station_list_with_a_schedule() -> None:
|
||||||
|
provider = TilderadioProvider()
|
||||||
|
assert provider.has_schedule is True
|
||||||
|
# the SomaFM wordmark's slot now holds tilderadio's own art and motto
|
||||||
|
assert provider.hero and not provider.hero_gradient
|
||||||
|
assert provider.hero_lines == (
|
||||||
|
"TILDERADIO",
|
||||||
|
"STREAMED BY / FOR USERS OF THE",
|
||||||
|
"TILDEVERSE",
|
||||||
|
)
|
||||||
|
assert SomaFMProvider().has_schedule is False
|
||||||
|
|
||||||
|
|
||||||
|
class _CatalogueApp:
|
||||||
|
"""A headless app on a fixed catalogue, for the picker tests."""
|
||||||
|
|
||||||
|
def __init__(self, provider: str = "somafm") -> None:
|
||||||
|
from beefy_player.app import BeefyPlayerApp
|
||||||
|
|
||||||
|
self.app = BeefyPlayerApp(
|
||||||
|
provider=provider, autoplay=False, offline=True, audio=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_p_opens_the_picker_and_switches_catalogue() -> None:
|
||||||
|
from beefy_player.widgets.providers import ProviderScreen
|
||||||
|
from beefy_player.widgets.schedule import SchedulePanel
|
||||||
|
from beefy_player.widgets.station_list import StationList
|
||||||
|
|
||||||
|
app = _CatalogueApp().app
|
||||||
|
async with app.run_test(size=(110, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.provider.id == "somafm"
|
||||||
|
assert app.query_one(StationList).display is True
|
||||||
|
assert app.query_one(SchedulePanel).display is False
|
||||||
|
assert app.query_one(HeaderBar).styles.height.value == 4
|
||||||
|
|
||||||
|
await pilot.press("p")
|
||||||
|
await pilot.pause()
|
||||||
|
assert isinstance(app.screen, ProviderScreen)
|
||||||
|
|
||||||
|
# walk down the alphabetical menu from the current entry to tilderadio
|
||||||
|
slugs = available_providers()
|
||||||
|
steps = (slugs.index("tilderadio") - slugs.index("somafm")) % len(slugs)
|
||||||
|
for _ in range(steps):
|
||||||
|
await pilot.press("down")
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("enter")
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert app.provider.id == "tilderadio"
|
||||||
|
assert app.config.provider == "tilderadio"
|
||||||
|
assert [station.id for station in app.stations] == ["tilderadio"]
|
||||||
|
# the left pane becomes the schedule; every hero is the same height
|
||||||
|
assert app.query_one(StationList).display is False
|
||||||
|
assert app.query_one(SchedulePanel).display is True
|
||||||
|
assert app.query_one(HeaderBar).styles.height.value == 4
|
||||||
|
assert "tilderadio" in app.title
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_escape_closes_the_picker_without_switching() -> None:
|
||||||
|
app = _CatalogueApp().app
|
||||||
|
async with app.run_test(size=(110, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("p")
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("escape")
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.provider.id == "somafm"
|
||||||
|
assert app.config.provider != "tilderadio"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_help_names_both_catalogues_and_the_switch_key() -> None:
|
||||||
|
app = _CatalogueApp().app
|
||||||
|
async with app.run_test(size=(120, 60)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("question_mark")
|
||||||
|
await pilot.pause()
|
||||||
|
text = rendered_text(app)
|
||||||
|
assert "CATALOGUES" in text
|
||||||
|
assert "somafm" in text and "tilderadio" in text
|
||||||
|
assert "switch station catalogue" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_remembers_a_station_per_catalogue() -> None:
|
||||||
|
config.Config(last_stations={"somafm": "defcon", "tilderadio": "tilderadio"}).save()
|
||||||
|
loaded = config.Config.load()
|
||||||
|
assert loaded.last_station_for("somafm") == "defcon"
|
||||||
|
assert loaded.last_station_for("tilderadio") == "tilderadio"
|
||||||
|
# a catalogue nobody has tuned yet falls back to the legacy single field
|
||||||
|
assert loaded.last_station_for("no-such-catalogue") == loaded.last_station
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_every_catalogue_header_is_the_same_height() -> None:
|
||||||
|
for slug in available_providers():
|
||||||
|
app = _CatalogueApp(slug).app
|
||||||
|
async with app.run_test(size=(110, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
header = app.query_one(HeaderBar)
|
||||||
|
assert header.styles.height.value == 4, slug
|
||||||
|
lines = [line.plain for line in header.render().renderables]
|
||||||
|
assert any("STOPPED" in line or "PLAYING" in line for line in lines), slug
|
||||||
|
|
||||||
|
|
||||||
|
class _StubPlayer:
|
||||||
|
"""Just enough Player for a tune: records what mpv would have played."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
import queue
|
||||||
|
|
||||||
|
self.events: queue.Queue = queue.Queue()
|
||||||
|
self.played: list[list[str]] = []
|
||||||
|
self.volume: int | None = None
|
||||||
|
self.backend: str | None = None
|
||||||
|
|
||||||
|
def play(self, urls) -> None:
|
||||||
|
self.played.append(list(urls))
|
||||||
|
|
||||||
|
def set_volume(self, value: int) -> None:
|
||||||
|
self.volume = value
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def toggle_pause(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _switch_to(app, pilot, slug: str) -> None:
|
||||||
|
slugs = available_providers()
|
||||||
|
steps = (slugs.index(slug) - slugs.index(app.provider.id)) % len(slugs)
|
||||||
|
await pilot.press("p")
|
||||||
|
await pilot.pause()
|
||||||
|
for _ in range(steps):
|
||||||
|
await pilot.press("down")
|
||||||
|
await pilot.press("enter")
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_switching_to_a_single_station_catalogue_tunes_it() -> None:
|
||||||
|
app = _CatalogueApp("somafm").app
|
||||||
|
async with app.run_test(size=(110, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
stub = _StubPlayer()
|
||||||
|
app.player = stub
|
||||||
|
app.audio_enabled = True
|
||||||
|
|
||||||
|
await _switch_to(app, pilot, "tilderadio")
|
||||||
|
|
||||||
|
assert app.provider.id == "tilderadio"
|
||||||
|
assert app.current is not None and app.current.id == "tilderadio"
|
||||||
|
assert stub.played, "a one-station catalogue should start playing"
|
||||||
|
assert app.playing_state != "stopped"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_switching_to_a_multi_station_catalogue_waits_for_enter() -> None:
|
||||||
|
app = _CatalogueApp("tilderadio").app
|
||||||
|
async with app.run_test(size=(110, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
stub = _StubPlayer()
|
||||||
|
app.player = stub
|
||||||
|
app.audio_enabled = True
|
||||||
|
|
||||||
|
await _switch_to(app, pilot, "somafm")
|
||||||
|
|
||||||
|
assert app.provider.id == "somafm"
|
||||||
|
assert len(app.stations) > 1
|
||||||
|
assert stub.played == [], "multi-station catalogues wait for enter"
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"""Radio Browser: a directory of stations, not a station."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from beefy_player.providers.radiobrowser import RadioBrowserProvider, api, fallback
|
||||||
|
|
||||||
|
ENTRY = {
|
||||||
|
"stationuuid": "96062a7b-0601-11e8-ae97-52543be04c81",
|
||||||
|
"name": "France Info",
|
||||||
|
"url_resolved": "http://direct.franceinfo.fr/live/franceinfo-midfi.mp3",
|
||||||
|
"url": "http://direct.franceinfo.fr/live/franceinfo-midfi.mp3",
|
||||||
|
"country": "France",
|
||||||
|
"language": "french",
|
||||||
|
"tags": "news,talk,politic",
|
||||||
|
"codec": "MP3",
|
||||||
|
"bitrate": 128,
|
||||||
|
"favicon": "https://www.francetvinfo.fr/icon.png",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_entry_becomes_a_station() -> None:
|
||||||
|
station = api.station_from_entry(ENTRY)
|
||||||
|
assert station is not None
|
||||||
|
assert station.id == ENTRY["stationuuid"]
|
||||||
|
assert station.title == "France Info"
|
||||||
|
assert station.description == "France · french"
|
||||||
|
assert station.genre_label == "news · talk · politic"
|
||||||
|
assert station.image.endswith("icon.png")
|
||||||
|
assert station.format_label == "mp3 · 128k"
|
||||||
|
assert station.provider == "radiobrowser"
|
||||||
|
|
||||||
|
|
||||||
|
def test_entries_without_a_stream_or_a_name_are_skipped() -> None:
|
||||||
|
assert api.station_from_entry({**ENTRY, "url_resolved": "", "url": ""}) is None
|
||||||
|
assert api.station_from_entry({**ENTRY, "name": " "}) is None
|
||||||
|
assert api.station_from_entry({**ENTRY, "stationuuid": ""}) is None
|
||||||
|
assert api.station_from_entry("not a dict") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_dedupes_and_sorts_by_name() -> None:
|
||||||
|
payload = [
|
||||||
|
{**ENTRY, "stationuuid": "b", "name": "Zed FM"},
|
||||||
|
{**ENTRY, "stationuuid": "a", "name": "Alpha Radio"},
|
||||||
|
{**ENTRY, "stationuuid": "a", "name": "Alpha Radio"},
|
||||||
|
]
|
||||||
|
stations = api.parse_stations(payload)
|
||||||
|
assert [station.title for station in stations] == ["Alpha Radio", "Zed FM"]
|
||||||
|
assert api.parse_stations(None) == []
|
||||||
|
assert api.parse_stations({}) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_hls_streams_survive_as_direct_urls() -> None:
|
||||||
|
hls = {
|
||||||
|
**ENTRY,
|
||||||
|
"stationuuid": "hls",
|
||||||
|
"url_resolved": "https://example.test/live/index.m3u8?token=1",
|
||||||
|
"codec": "AAC",
|
||||||
|
"bitrate": 64,
|
||||||
|
}
|
||||||
|
station = api.station_from_entry(hls)
|
||||||
|
assert station is not None
|
||||||
|
assert station.playlists[0]["format"] == "aac"
|
||||||
|
assert station.format_label == "aac · 64k"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_stations_queries_all_searchable_fields() -> None:
|
||||||
|
entries = {
|
||||||
|
"name": {**ENTRY, "stationuuid": "name", "name": "Jazz Name"},
|
||||||
|
"tag": {**ENTRY, "stationuuid": "tag", "name": "Zed Jazz Tag"},
|
||||||
|
"country": {**ENTRY, "stationuuid": "country", "name": "Country Jazz"},
|
||||||
|
"language": {**ENTRY, "stationuuid": "language", "name": "Language Jazz"},
|
||||||
|
}
|
||||||
|
seen: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
params = dict(request.url.params)
|
||||||
|
seen.append(params)
|
||||||
|
field = next(name for name in api._SEARCH_FIELDS if name in params)
|
||||||
|
payload = [entries[field]]
|
||||||
|
if field in ("name", "tag"):
|
||||||
|
payload.append(ENTRY) # the same station may match twice
|
||||||
|
return httpx.Response(200, json=payload)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
stations = await api.search_stations(client, "jazz")
|
||||||
|
|
||||||
|
assert {station.id for station in stations} == {
|
||||||
|
ENTRY["stationuuid"],
|
||||||
|
"name",
|
||||||
|
"tag",
|
||||||
|
"country",
|
||||||
|
"language",
|
||||||
|
}
|
||||||
|
assert [params["limit"] for params in seen] == [
|
||||||
|
str(api.SEARCH_LIMIT)
|
||||||
|
] * len(api._SEARCH_FIELDS)
|
||||||
|
for params in seen:
|
||||||
|
assert params["hidebroken"] == "true"
|
||||||
|
assert params["order"] == "clickcount"
|
||||||
|
assert params["reverse"] == "true"
|
||||||
|
assert {next(name for name in api._SEARCH_FIELDS if name in params) for params in seen} == set(
|
||||||
|
api._SEARCH_FIELDS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_stations_ignores_a_failed_field(monkeypatch) -> None:
|
||||||
|
async def fake_fetch_json(client, url):
|
||||||
|
if "name=jazz" in url:
|
||||||
|
raise RuntimeError("name search is down")
|
||||||
|
return [ENTRY]
|
||||||
|
|
||||||
|
monkeypatch.setattr(api, "fetch_json", fake_fetch_json)
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
stations = await api.search_stations(client, "jazz")
|
||||||
|
assert [station.id for station in stations] == [ENTRY["stationuuid"]]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_empty_search_does_not_hit_the_api() -> None:
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
raise AssertionError("empty queries must not make a request")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
assert await api.search_stations(client, " ") == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_stations_caches_the_directory() -> None:
|
||||||
|
payload = [ENTRY, {**ENTRY, "stationuuid": "x", "name": "Another"}]
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
params = dict(request.url.params)
|
||||||
|
assert params["hidebroken"] == "true"
|
||||||
|
assert params["limit"] == str(api.PAGE_SIZE)
|
||||||
|
assert params["offset"] == "0"
|
||||||
|
return httpx.Response(200, json=payload)
|
||||||
|
|
||||||
|
provider = RadioBrowserProvider()
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
stations = await provider.fetch_stations(client)
|
||||||
|
assert [station.title for station in stations] == ["France Info", "Another"]
|
||||||
|
assert [station.title for station in provider.load_cached_stations()] == [
|
||||||
|
"France Info",
|
||||||
|
"Another",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_station_page_continues_in_catalogue_order() -> None:
|
||||||
|
first = [
|
||||||
|
{**ENTRY, "stationuuid": f"id-{index}", "name": f"Station {index}"}
|
||||||
|
for index in range(api.PAGE_SIZE)
|
||||||
|
]
|
||||||
|
last = [{**ENTRY, "stationuuid": "last", "name": "Last Station"}]
|
||||||
|
seen: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
params = dict(request.url.params)
|
||||||
|
seen.append(params)
|
||||||
|
return httpx.Response(200, json=first if params["offset"] == "100" else last)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
page = await api.fetch_station_page(client, 100)
|
||||||
|
end = await api.fetch_station_page(client, 200)
|
||||||
|
|
||||||
|
assert [station.id for station in page.stations] == [
|
||||||
|
f"id-{index}" for index in range(api.PAGE_SIZE)
|
||||||
|
]
|
||||||
|
assert page.next_offset == 200
|
||||||
|
assert end.next_offset is None
|
||||||
|
assert [params["offset"] for params in seen] == ["100", "200"]
|
||||||
|
assert [params["limit"] for params in seen] == [str(api.PAGE_SIZE)] * 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_stations_by_ids_uses_the_uuid_endpoint() -> None:
|
||||||
|
seen: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
seen.append(dict(request.url.params))
|
||||||
|
return httpx.Response(200, json=[ENTRY])
|
||||||
|
|
||||||
|
station_id = ENTRY["stationuuid"]
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
stations = await api.fetch_stations_by_ids(
|
||||||
|
client, ["defcon", station_id, station_id]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [station.id for station in stations] == [station_id]
|
||||||
|
assert len(seen) == 1
|
||||||
|
assert seen[0]["uuids"] == station_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_failed_fetch_does_not_kill_the_catalogue() -> None:
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(500, json={})
|
||||||
|
|
||||||
|
provider = RadioBrowserProvider()
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
await provider.fetch_stations(client)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_bundled_directory_works_offline() -> None:
|
||||||
|
providers = fallback.fallback_stations()
|
||||||
|
assert len(providers) == 10
|
||||||
|
for station in providers:
|
||||||
|
assert station.playlists and station.provider == "radiobrowser"
|
||||||
|
assert station.playlists[0]["url"].startswith("https://")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_directory_offers_no_history_and_no_schedule() -> None:
|
||||||
|
provider = RadioBrowserProvider()
|
||||||
|
assert provider.has_schedule is False
|
||||||
|
assert provider.bandcamp is False
|
||||||
|
assert len(provider.hero) == 3
|
||||||
|
assert provider.hero_lines[0] == "RADIO BROWSER"
|
||||||
|
assert provider.load_cached_history("anything") == []
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Radio Paradise: five mixes, one now-playing API, rolling history."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from beefy_player.providers.radioparadise import RadioParadiseProvider, api
|
||||||
|
|
||||||
|
NOW = {
|
||||||
|
"time": 244,
|
||||||
|
"artist": "Eric Bibb",
|
||||||
|
"title": "Don't Ever Let Nobody Drag Your Spirit Down",
|
||||||
|
"album": "Painting Signs",
|
||||||
|
"year": "1999",
|
||||||
|
"cover": "https://img.radioparadise.com/covers/l/1.jpg",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_now_keeps_the_cover_art() -> None:
|
||||||
|
song = api.parse_now(NOW)
|
||||||
|
assert song is not None
|
||||||
|
assert (song.artist, song.title) == ("Eric Bibb", NOW["title"])
|
||||||
|
assert song.album == "Painting Signs"
|
||||||
|
assert song.art_url.endswith("1.jpg")
|
||||||
|
assert api.parse_now({}) is None
|
||||||
|
assert api.parse_now(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_playlists_prefer_flac_and_carry_real_bitrates() -> None:
|
||||||
|
playlists = api.playlists(("flac", "aac-320", "mp3-320", "mp3-128"))
|
||||||
|
assert [entry["url"].rsplit("/", 1)[1] for entry in playlists] == [
|
||||||
|
"flac",
|
||||||
|
"aac-320",
|
||||||
|
"mp3-320",
|
||||||
|
"mp3-128",
|
||||||
|
]
|
||||||
|
assert [entry["quality"] for entry in playlists] == [
|
||||||
|
"highest",
|
||||||
|
"high",
|
||||||
|
"high",
|
||||||
|
"high",
|
||||||
|
]
|
||||||
|
assert playlists[0]["bitrate"] == "1411"
|
||||||
|
assert playlists[-1]["bitrate"] == "128"
|
||||||
|
|
||||||
|
|
||||||
|
def test_five_mixes_with_channel_numbers() -> None:
|
||||||
|
provider = RadioParadiseProvider()
|
||||||
|
stations = provider.fallback_stations()
|
||||||
|
assert [station.id for station in stations] == [
|
||||||
|
"main",
|
||||||
|
"mellow",
|
||||||
|
"rock",
|
||||||
|
"global",
|
||||||
|
"beyond",
|
||||||
|
]
|
||||||
|
assert api.CHANNEL_BY_STATION["beyond"] == 5
|
||||||
|
assert stations[0].format_label.startswith("flac")
|
||||||
|
# the alternates are FLAC-only relays
|
||||||
|
assert [entry["url"].rsplit("/", 1)[1] for entry in stations[1].playlists] == [
|
||||||
|
"mellow-flac"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_history_polls_the_right_channel_and_rolls() -> None:
|
||||||
|
seen: list[str] = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
seen.append(str(request.url))
|
||||||
|
return httpx.Response(200, json=NOW)
|
||||||
|
|
||||||
|
provider = RadioParadiseProvider()
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
first = await provider.fetch_history(client, "mellow")
|
||||||
|
again = await provider.fetch_history(client, "mellow")
|
||||||
|
|
||||||
|
assert any("chan=1" in url for url in seen)
|
||||||
|
assert len(first) == 1
|
||||||
|
assert again[0].key() == first[0].key()
|
||||||
|
assert again[0].played_at == first[0].played_at # first sighting kept
|
||||||
|
assert provider.load_cached_stations()[0].title == "The Main Mix"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_unknown_station_has_no_history() -> None:
|
||||||
|
provider = RadioParadiseProvider()
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, json=NOW))) as client:
|
||||||
|
assert await provider.fetch_history(client, "no-such-mix") == []
|
||||||
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user