diff --git a/LICENSE b/LICENSE index 29a4791..d02a250 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (C) 2026 somafm-tui contributors +Copyright (C) 2026 beefy-player contributors DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE Version 2, December 2004 diff --git a/README.md b/README.md index dd2d8d9..652925f 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,25 @@ -# somafm-tui +# Beefy Player -![somafm-tui demo](assets/somafm-tui-demo.gif) +![beefy-player demo](assets/beefy-player-demo.gif) -A flashy terminal radio for **[SomaFM](https://somafm.com)** — a live spectrum -analyser, song history, and full desktop media integration, drawn with your -terminal's own ANSI palette on the terminal's default background so it fits -any theme (including transparent terminals). +A flashy terminal player for **internet radio** — a live spectrum analyser, +song history, and full desktop media integration, drawn with your terminal's +own ANSI palette on the terminal's default background so it fits any theme +(including transparent terminals). + +The station catalogue is pluggable. Beefy Player ships with +**[SomaFM](https://somafm.com)** (its first and, at the moment, only provider), +and the UI never talks to a station catalogue directly: it asks a +[provider](#providers) for stations, song history and stream URLs. Adding +another internet-radio catalogue is a self-contained module, not a rewrite. It runs the same way on **Linux** (PulseAudio/PipeWire) and **macOS** (CoreAudio, iTerm2/Kitty/WezTerm): MPRIS on the desktop bus where that exists, macOS Now Playing (Control Center and the media keys) where it does not. -No existing SomaFM client is used: the station list, playlists, song history, +No existing radio client is used: the station list, playlists, song history, audio control, spectrum visualiser and media-session integration are all -implemented here from the public SomaFM endpoints and open protocols. +implemented here from public endpoints and open protocols. ## Features @@ -33,17 +39,19 @@ implemented here from the public SomaFM endpoints and open protocols. iTerm2 inline-image protocol. Images occupy exactly the ON AIR art cell box and fall back to the procedural ANSI mosaic for other terminals, failed loads, or blocked graphics passthrough. Images are cached in - `~/.cache/somafm-tui/artwork`. + `~/.cache/beefy-player/artwork`. - **Now playing** — artist, title, album, DJ, listeners, format/bitrate and "played N minutes ago", with procedural ANSI-shaded album art that changes per track. When the current track can be found on Bandcamp, a clickable **Bandcamp ↗** link appears below it; otherwise the line stays hidden. -- **Last played** — the full recent history from `somafm.com/songs/.json`. -- **Every station** — the whole `channels.json` roster (≈50 channels), live - listener counts, genre search. +- **Last played** — the full recent history, newest first. +- **Every station** — with SomaFM that is the whole `channels.json` roster + (≈50 channels), live listener counts and genre search. - **Favourites** that float to the top, remembered between runs. +- **Pluggable catalogues** — one `providers` module per radio network, selected + with `--provider` and remembered in the config file. - **Desktop media integration** — on Linux an - `org.mpris.MediaPlayer2.somafmtui.instance` player exposes + `org.mpris.MediaPlayer2.beefyplayer.instance` player exposes `PlaybackStatus`, `Metadata` (title/artist/album/artUrl), `Volume`, and `Play`/`Pause`/`PlayPause`/`Stop`/`Next`/`Previous`, so GNOME/KDE widgets, lock screens, `playerctl` and the media keys pick it up. On macOS the audio @@ -58,7 +66,7 @@ implemented here from the public SomaFM endpoints and open protocols. ## Requirements -`somafm-tui` is a normal Python application: there is nothing to compile and +`beefy-player` is a normal Python application: there is nothing to compile and no distro-specific packaging. It runs on any Linux distribution or macOS with Python **3.10+** and a few command-line audio tools on `$PATH`. @@ -127,24 +135,63 @@ needed. If the permission is denied the tap only ever hands out silence, the analyser notices and falls back to `FFT` instead of showing dead bars. The tap records the **default output device**, which is also what mpv uses; if -you point mpv elsewhere with `SOMAFM_MPV_ARGS='--audio-device=...'` the +you point mpv elsewhere with `BEEFY_MPV_ARGS='--audio-device=...'` the analyser falls back to `FFT`. Terminal image rendering uses [Pillow](https://python-pillow.org/) and is implemented directly (Kitty TGP + iTerm2 OSC 1337) — no terminal probing. +## Providers + +A *provider* is one station catalogue. It knows how to fetch the station list, +how to fetch a station's recently played songs, and how to turn a station into +a stream URL. Everything else — the UI, the player, the spectrum, the media +session — is provider-neutral. + +```python +# beefy_player/providers/base.py +class RadioProvider(ABC): + id, name, homepage, brand, tagline, station_source, history_source, user_agent + + async def fetch_stations(self, client) -> list[Station]: ... + def load_cached_stations(self) -> list[Station]: ... + def fallback_stations(self) -> list[Station]: ... + async def fetch_history(self, client, station_id) -> list[Song]: ... + def load_cached_history(self, station_id) -> list[Song]: ... + def fallback_urls(self, station) -> list[str]: ... + async def stream_candidates(self, client, station) -> list[str]: ... +``` + +SomaFM is the reference implementation, and it is quarantined in its own +package: the `channels.json` schema, the `songs/.json` history, the PLS +layout and the guessed icecast mirrors all live in +`beefy_player/providers/somafm/`. Nothing outside that package imports it; the +app only ever holds a `RadioProvider`. + +Adding a catalogue: + +1. create `beefy_player/providers//` with a `RadioProvider` subclass, +2. register one instance in `beefy_player/providers/__init__.py`, +3. run it with `beefy-player --provider ` (or set `"provider"` in + `~/.config/beefy-player/config.json`). + +`RadioProvider.stream_candidates()` already implements the common strategy — +resolve the station's playlists (with an on-disk cache), then try +`fallback_urls()` — so most providers only have to supply the catalogue, +history and mirror URLs. + ## Run From the repository root, sync the environment and start the app: ```bash uv sync # create .venv and install exactly what uv.lock pins -uv run somafm-tui # run inside that environment +uv run beefy-player # run inside that environment ``` You do **not** need to activate the venv. `uv run` uses `.venv` automatically, and re-syncs it first if `pyproject.toml` or `uv.lock` changed. If you prefer a -traditional shell, run `source .venv/bin/activate` and then `somafm-tui`. +traditional shell, run `source .venv/bin/activate` and then `beefy-player`. ### Keeping the uv environment in sync @@ -163,20 +210,25 @@ rm -rf .venv && uv sync # last resort if the venv was built by an older Python Useful flags: ```bash -uv run somafm-tui --play # start the last station immediately -uv run somafm-tui --station defcon # pick a station -uv run somafm-tui --volume 55 -uv run somafm-tui --offline # no network; cached/bundled stations -uv run somafm-tui --no-mpris # no MPRIS / Now Playing, no media keys -uv run somafm-tui --list # print station ids -uv run somafm-tui --no-audio # UI demo without mpv/ffmpeg -uv run somafm-tui --spectrum-source monitor # force synced output capture -uv run somafm-tui --spectrum-source stream # force the muted ffmpeg tap -SOMAFM_IMAGES=0 uv run somafm-tui # force the ANSI artwork mosaic -SOMAFM_IMAGES=tgp uv run somafm-tui # force Kitty/TGP artwork -SOMAFM_IMAGES=iterm2 uv run somafm-tui # force iTerm2/WezTerm artwork +uv run beefy-player --provider somafm # pick a station catalogue +uv run beefy-player --play # start the last station immediately +uv run beefy-player --station defcon # pick a station +uv run beefy-player --volume 55 +uv run beefy-player --offline # no network; cached/bundled stations +uv run beefy-player --no-mpris # no MPRIS / Now Playing, no media keys +uv run beefy-player --list # print station ids +uv run beefy-player --no-audio # UI demo without mpv/ffmpeg +uv run beefy-player --spectrum-source monitor # force synced output capture +uv run beefy-player --spectrum-source stream # force the muted ffmpeg tap +BEEFY_IMAGES=0 uv run beefy-player # force the ANSI artwork mosaic +BEEFY_IMAGES=tgp uv run beefy-player # force Kitty/TGP artwork +BEEFY_IMAGES=iterm2 uv run beefy-player # force iTerm2/WezTerm artwork ``` +Environment variables are prefixed `BEEFY_`; the pre-rename `SOMAFM_` spellings +(`SOMAFM_IMAGES`, `SOMAFM_MPV_ARGS`, `SOMAFM_SPECTRUM_DEVICE`) are still +honoured, so an existing shell profile keeps working. + The UI wants roughly **84×24**; it also works smaller, just less pretty. ## Keys @@ -194,7 +246,7 @@ The UI wants roughly **84×24**; it also works smaller, just less pretty. | `/` | filter by title, genre, description | | `esc` | clear filter / close search | | `v` | visualiser: mirror → bars → wave → waterfall → radial → braille | -| `r` | refresh channels + song history | +| `r` | refresh stations + song history | | `g` / `G` | top / bottom | | `?` | help | | `q` | quit | @@ -202,7 +254,8 @@ The UI wants roughly **84×24**; it also works smaller, just less pretty. ## Desktop media widgets (MPRIS / Now Playing) **Linux** — while playing, the app exports -`org.mpris.MediaPlayer2.somafmtui.instance` on the **session bus**. Check it: +`org.mpris.MediaPlayer2.beefyplayer.instance` on the **session bus**. Check +it: ```bash playerctl -l @@ -210,14 +263,15 @@ playerctl metadata --format '{{artist}} — {{title}} ({{album}})' playerctl play-pause ``` -`mpris:artUrl` is the track's album art when SomaFM provides it, otherwise the -channel logo. If there is no session bus (SSH without `dbus-run-session`, -containers, …) MPRIS is skipped and the status bar shows `mpris off`. +`mpris:artUrl` is the track's album art when the provider supplies it, +otherwise the channel logo. If there is no session bus (SSH without +`dbus-run-session`, containers, …) MPRIS is skipped and the status bar shows +`mpris off`. **macOS** — mpv owns the system Now Playing session, so Control Center, the menu-bar widget and the keyboard media keys (▶︎/⏸, ⏭, ⏮) all target this app while it is playing. mpv publishes the live stream title (the same -“Artist - Title” that SomaFM sends with the audio), and the media keys are +“Artist - Title” that the station sends with the audio), and the media keys are routed back into the TUI, so `⏭`/`⏮` really change station and the panel, the spectrum and the station list stay in sync. The status bar shows `nowplaying on`. There is no D-Bus and no extra dependency involved, and @@ -229,63 +283,88 @@ spectrum and the station list stay in sync. The status bar shows ## How it works -- `somafm_tui/api.py` — async `httpx` client for: +``` +beefy_player/ + app.py Textual app: layout, keys, orchestration + models.py Song + Station, provider-neutral + config.py XDG paths, settings, BEEFY_* env helpers + providers/ + base.py RadioProvider interface + HTTP/playlist toolkit + somafm/ the SomaFM catalogue (API, bundled fallback list) + player.py mpv over a Unix-socket JSON IPC channel + spectrum.py analyser (parec / Core Audio tap / ffmpeg / sim) + macos_tap.py macOS output capture (ctypes + Core Audio) + mediasession.py platform-neutral desktop media face + mpris.py dbus-next MPRIS service + artwork.py Kitty TGP / iTerm2 images with an ANSI mosaic fallback + bandcamp.py "is this track on Bandcamp?" lookups + widgets/ the Textual widgets +``` + +- `beefy_player/providers/base.py` — the provider interface and the shared + toolkit: `httpx` client factory plus `.pls`/`.m3u` parsing and resolution + (cached for 8 hours), so providers only describe their own catalogue. +- `beefy_player/providers/somafm/api.py` — async `httpx` client for: - `channels.json` → stations, genres, listeners, `.pls` playlist URLs - `.pls` → resolved direct icecast URLs (cached 8h) - `songs/.json` → last played tracks (cached on disk) Playlist and channel caching means the UI opens instantly and still works offline. -- `somafm_tui/player.py` — mpv over a Unix-socket JSON IPC channel. Observes +- `beefy_player/app.py` — holds one provider and one `Player`, ticks at 20 Hz, + and hands the widgets plain `Station`/`Song` objects. +- `beefy_player/player.py` — mpv over a Unix-socket JSON IPC channel. Observes `pause`/`volume`, handles mirror failover on `end-file`, auto-restarts a dead engine, and uses `SIGSTOP`/`SIGCONT` when falling back to `ffplay`. On macOS it also asks mpv to publish Now Playing and maps the media keys onto `script-message`s that come back over the same IPC socket. -- `somafm_tui/spectrum.py` — analyser (44.1 kHz mono s16le from `parec`, or +- `beefy_player/spectrum.py` — analyser (44.1 kHz mono s16le from `parec`, or the device's native float32 rate from the macOS tap). Prefers capturing the real output: `parec` (PulseAudio/PipeWire monitor) on Linux, or a native Core Audio process tap on macOS (SYNC, see `macos_tap.py`). Otherwise a muted low-latency `ffmpeg` decode of the stream (FFT). Hann-windowed 2048-point FFT, log band grouping, auto-gain, fast attack/decay smoothing, peak hold and waveform downsampling. -- `somafm_tui/macos_tap.py` — the macOS output capture, in pure `ctypes` +- `beefy_player/macos_tap.py` — the macOS output capture, in pure `ctypes` against Core Audio: `CATapDescription` → `AudioHardwareCreateProcessTap` → private aggregate device → IOProc. No driver, no helper binary, no ffmpeg. -- `somafm_tui/mediasession.py` — picks the desktop media backend (MPRIS on +- `beefy_player/mediasession.py` — picks the desktop media backend (MPRIS on Linux, Now Playing on macOS) so the app has one platform-neutral interface. -- `somafm_tui/mpris.py` — `dbus-next` MPRIS service in its own thread with a +- `beefy_player/mpris.py` — `dbus-next` MPRIS service in its own thread with a thread-safe command queue back into the UI (only loaded on D-Bus platforms). -- `somafm_tui/bandcamp.py` — searches Bandcamp's public autocomplete API for +- `beefy_player/bandcamp.py` — searches Bandcamp's public autocomplete API for the now-playing track (artist + track first, then artist + album, then the album page) and returns a track URL when there is a match. -- `somafm_tui/widgets/` — Textual widgets rendered with native ANSI colours; +- `beefy_player/widgets/` — Textual widgets rendered with native ANSI colours; backgrounds are `ansi_default` (`SGR 49`), so transparent terminals stay transparent. ### Files ``` -~/.config/somafm-tui/config.json favourites, volume, last station, mode -~/.cache/somafm-tui/channels.json last good station list -~/.cache/somafm-tui/playlists.json resolved stream URLs -~/.cache/somafm-tui/songs/.json song history snapshots -~/.cache/somafm-tui/artwork/ cached album/channel artwork +~/.config/beefy-player/config.json favourites, volume, last station, mode +~/.cache/beefy-player/channels.json last good station list +~/.cache/beefy-player/playlists.json resolved stream URLs +~/.cache/beefy-player/songs/.json song history snapshots +~/.cache/beefy-player/artwork/ cached album/channel artwork ``` These stay in the XDG locations on every platform (macOS included), so the same dotfiles/config work everywhere; `XDG_CONFIG_HOME`, `XDG_CACHE_HOME` and -`XDG_RUNTIME_DIR` are honoured when set. +`XDG_RUNTIME_DIR` are honoured when set. The app started life as +`somafm-tui`: on first run a `~/.config/somafm-tui/config.json` is copied over +so favourites and volume survive the rename. ## Notes / troubleshooting - **Artwork shows the mosaic?** Real images are auto-enabled for Kitty, - iTerm2 and WezTerm. Force a protocol with `SOMAFM_IMAGES=tgp` (Kitty/TGP) or - `SOMAFM_IMAGES=iterm2` (iTerm2/WezTerm); `SOMAFM_IMAGES=0` forces the ANSI + iTerm2 and WezTerm. Force a protocol with `BEEFY_IMAGES=tgp` (Kitty/TGP) or + `BEEFY_IMAGES=iterm2` (iTerm2/WezTerm); `BEEFY_IMAGES=0` forces the ANSI mosaic. iTerm2 on macOS is detected from `TERM_PROGRAM`/`LC_TERMINAL`, and empty or `0` values in `TMUX`/`ZELLIJ`/`STY` (exported by some wrappers) are ignored so they cannot downgrade it. Kitty inside tmux is attempted through tmux passthrough (enable `set -g allow-passthrough on`); iTerm2/WezTerm inside tmux default to the mosaic. Images are cached under - `~/.cache/somafm-tui/artwork`. + `~/.cache/beefy-player/artwork`. - **Clock shows UTC?** The clock and song history use the system zone from `/etc/localtime`; a non-UTC `TZ` overrides it. A container or dev shell can inherit a build-time `TZ=UTC`, which the app deliberately ignores; bind the @@ -293,23 +372,23 @@ same dotfiles/config work everywhere; `XDG_CONFIG_HOME`, `XDG_CACHE_HOME` and launches the app. - **MPRIS says off/error?** No session bus. Run from your desktop session (the shell profile picks up `$XDG_RUNTIME_DIR/bus`). `dbus-run-session -- uv - run somafm-tui` starts a private bus for testing, but desktop widgets won't + run beefy-player` starts a private bus for testing, but desktop widgets won't see it. macOS does not use MPRIS at all — see “Now Playing” above. -- **No audio?** `somafm-tui` needs `mpv` (preferred) or `ffplay`. Without +- **No audio?** `beefy-player` needs `mpv` (preferred) or `ffplay`. Without either, the UI still runs and tells you. Extra mpv args can be injected with - `SOMAFM_MPV_ARGS` (e.g. `SOMAFM_MPV_ARGS='--audio-device=...'`, which is the + `BEEFY_MPV_ARGS` (e.g. `BEEFY_MPV_ARGS='--audio-device=...'`, which is the macOS way to choose an output device). - **Spectrum says `FFT` instead of `SYNC`?** The monitor is unavailable or failed, or you forced the stream tap. The bars are still real audio, but the second stream can drift relative to playback. Use `--spectrum-source - monitor` to force sync, or `SOMAFM_SPECTRUM_DEVICE=` on Linux to pick + monitor` to force sync, or `BEEFY_SPECTRUM_DEVICE=` on Linux to pick a specific monitor. `SIM` means the bars are synthesized. -- **Diagnose artwork:** `uv run somafm-tui --diagnose-images` prints the +- **Diagnose artwork:** `uv run beefy-player --diagnose-images` prints the detected protocol, widget class, terminal env, dependency versions and cache - path. If `protocol` is `None`, force it with `SOMAFM_IMAGES=tgp` (Kitty) or - `SOMAFM_IMAGES=iterm2` (iTerm2/WezTerm). + path. If `protocol` is `None`, force it with `BEEFY_IMAGES=tgp` (Kitty) or + `BEEFY_IMAGES=iterm2` (iTerm2/WezTerm). - **`SYNC` but the bars are flat?** mpv may be playing to a different sink (or - directly to ALSA). Point `SOMAFM_SPECTRUM_DEVICE` at that sink's monitor, or + directly to ALSA). Point `BEEFY_SPECTRUM_DEVICE` at that sink's monitor, or use `--spectrum-source stream`. - **macOS: spectrum stays `FFT`?** Grant your terminal **System Audio Recording** permission (*System Settings → Privacy & Security → System Audio @@ -340,7 +419,7 @@ manylinux wheels such as NumPy that expect `libstdc++.so.6` in the usual ```bash nix-shell # enter the FHS dev shell uv sync -uv run somafm-tui +uv run beefy-player ``` - If you see `libstdc++.so.6: cannot open shared object file`, make sure you diff --git a/assets/somafm-tui-demo.gif b/assets/beefy-player-demo.gif similarity index 100% rename from assets/somafm-tui-demo.gif rename to assets/beefy-player-demo.gif diff --git a/beefy_player/__init__.py b/beefy_player/__init__.py new file mode 100644 index 0000000..66f77e1 --- /dev/null +++ b/beefy_player/__init__.py @@ -0,0 +1,3 @@ +"""Beefy Player — a flashy terminal player for internet radio.""" + +__version__ = "0.1.0" diff --git a/somafm_tui/__main__.py b/beefy_player/__main__.py similarity index 100% rename from somafm_tui/__main__.py rename to beefy_player/__main__.py diff --git a/somafm_tui/app.py b/beefy_player/app.py similarity index 100% rename from somafm_tui/app.py rename to beefy_player/app.py diff --git a/somafm_tui/artwork.py b/beefy_player/artwork.py similarity index 93% rename from somafm_tui/artwork.py rename to beefy_player/artwork.py index ee4bf8d..8d8ffe4 100644 --- a/somafm_tui/artwork.py +++ b/beefy_player/artwork.py @@ -1,16 +1,16 @@ """Terminal artwork support. The TUI draws a procedural ANSI mosaic when the terminal cannot display real -images. On terminals with a graphics protocol we fetch the SomaFM album art -(or channel logo) and render it directly, with the mosaic kept behind it: +images. On terminals with a graphics protocol we fetch the station's album +art (or channel logo) and render it directly, with the mosaic behind it: * Kitty -> Terminal Graphics Protocol (direct image placement) * iTerm2 / WezTerm -> native iTerm2 inline images (OSC 1337) The protocols are implemented directly; there is no terminal capability probe and no extra terminal input, so nothing leaks into the shell at exit. -Set ``SOMAFM_IMAGES=0`` to force the ANSI fallback, or ``SOMAFM_IMAGES=tgp`` / -``SOMAFM_IMAGES=iterm2`` to force a protocol. +Set ``BEEFY_IMAGES=0`` to force the ANSI fallback, or ``BEEFY_IMAGES=tgp`` / +``BEEFY_IMAGES=iterm2`` to force a protocol. """ from __future__ import annotations @@ -47,7 +47,9 @@ def detect_protocol(environ: Mapping[str, str] | None = None) -> str | None: env = os.environ if environ is None else environ if "NO_COLOR" in env: return None - override = (env.get("SOMAFM_IMAGES") or "").strip().lower() + override = ( + env.get("BEEFY_IMAGES") or env.get("SOMAFM_IMAGES") or "" + ).strip().lower() if override in _OFF_VALUES: return None if override in _PROTOCOL_VALUES: @@ -154,7 +156,7 @@ def cell_size() -> tuple[int, int]: def _cache_path(url: str): digest = hashlib.sha1(url.encode("utf-8")).hexdigest() - return config.CACHE_DIR / "artwork" / digest + return config.ARTWORK_CACHE_DIR / digest def load_cached(url: str) -> bytes | None: @@ -197,13 +199,13 @@ def diagnostics() -> dict[str, object]: "ITERM_SESSION_ID": env.get("ITERM_SESSION_ID", ""), "TMUX": env.get("TMUX", ""), "ZELLIJ": env.get("ZELLIJ", ""), - "SOMAFM_IMAGES": env.get("SOMAFM_IMAGES", ""), + "BEEFY_IMAGES": env.get("BEEFY_IMAGES") or env.get("SOMAFM_IMAGES", ""), "NO_COLOR": "set" if "NO_COLOR" in env else "", "protocol": PROTOCOL, "supported": supported(), "cell_size": cell_size(), "pillow": pkg_version("pillow"), - "cache": str(config.CACHE_DIR / "artwork"), + "cache": str(config.ARTWORK_CACHE_DIR), } diff --git a/somafm_tui/bandcamp.py b/beefy_player/bandcamp.py similarity index 100% rename from somafm_tui/bandcamp.py rename to beefy_player/bandcamp.py diff --git a/somafm_tui/config.py b/beefy_player/config.py similarity index 61% rename from somafm_tui/config.py rename to beefy_player/config.py index d63742b..e6f520f 100644 --- a/somafm_tui/config.py +++ b/beefy_player/config.py @@ -2,10 +2,14 @@ Everything lives in the usual XDG places: - ~/.config/somafm-tui/config.json favourites, volume, last station - ~/.cache/somafm-tui/channels.json last good copy of somafm.com/channels.json - ~/.cache/somafm-tui/playlists.json resolved .pls -> stream URLs - ~/.cache/somafm-tui/songs/.json last good copy of the song history + ~/.config/beefy-player/config.json favourites, volume, station + ~/.cache/beefy-player/channels.json last good station list + ~/.cache/beefy-player/playlists.json resolved .pls -> stream URLs + ~/.cache/beefy-player/songs/.json last good song history + ~/.cache/beefy-player/artwork/ cached album/channel artwork + +Settings from the pre-rename ``somafm-tui`` directory are copied over once, so +upgrading does not lose favourites or volume. """ from __future__ import annotations @@ -13,11 +17,26 @@ from __future__ import annotations import contextlib import json import os +import shutil from dataclasses import asdict, dataclass, field from pathlib import Path from typing import Any -APP_NAME = "somafm-tui" +from . import __version__ + +#: Directory name for config, cache and runtime files. +APP_NAME = "beefy-player" + +#: Name the app used before it became Beefy Player; only read, once, to migrate. +LEGACY_APP_NAME = "somafm-tui" + +#: What we send as ``User-Agent`` to catalogues, icecast mirrors and mpv. +USER_AGENT = f"{APP_NAME}/{__version__} (terminal radio)" + +#: Environment variables are namespaced ``BEEFY_*``; the old ``SOMAFM_*`` names +#: are still honoured so existing shell profiles keep working. +ENV_PREFIX = "BEEFY_" +LEGACY_ENV_PREFIX = "SOMAFM_" def _xdg(env_var: str, fallback: str) -> Path: @@ -27,6 +46,14 @@ def _xdg(env_var: str, fallback: str) -> Path: return Path.home() / fallback +def env_var(suffix: str) -> str: + """``BEEFY_``, falling back to the legacy ``SOMAFM_``.""" + value = os.environ.get(ENV_PREFIX + suffix) + if value is None: + value = os.environ.get(LEGACY_ENV_PREFIX + suffix) + return (value or "").strip() + + CONFIG_DIR = _xdg("XDG_CONFIG_HOME", ".config") / APP_NAME CACHE_DIR = _xdg("XDG_CACHE_HOME", ".cache") / APP_NAME RUNTIME_DIR = Path(os.environ.get("XDG_RUNTIME_DIR") or "/tmp") / APP_NAME @@ -35,6 +62,10 @@ CONFIG_PATH = CONFIG_DIR / "config.json" CHANNELS_CACHE = CACHE_DIR / "channels.json" PLAYLIST_CACHE = CACHE_DIR / "playlists.json" SONGS_CACHE_DIR = CACHE_DIR / "songs" +ARTWORK_CACHE_DIR = CACHE_DIR / "artwork" + +#: Pre-rename location, read once by :meth:`Config.load`. +LEGACY_CONFIG_PATH = _xdg("XDG_CONFIG_HOME", ".config") / LEGACY_APP_NAME / "config.json" VISUALIZERS = ("both", "bars", "wave", "waterfall", "radial", "braille") @@ -65,6 +96,21 @@ def write_json(path: Path, payload: Any) -> None: atomic_write_text(path, json.dumps(payload, indent=2, ensure_ascii=False)) +def migrate_legacy_state() -> None: + """Copy a pre-rename config file into place, once. + + Favourites, volume and the last station are the only state worth keeping; + caches are rebuilt on the next run. + """ + try: + if CONFIG_PATH.exists() or not LEGACY_CONFIG_PATH.is_file(): + return + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + shutil.copyfile(LEGACY_CONFIG_PATH, CONFIG_PATH) + except OSError: + pass + + @dataclass(slots=True) class Config: favorites: list[str] = field(default_factory=list) @@ -72,6 +118,8 @@ class Config: volume: int = 70 visualizer: str = "both" autoplay: bool = False + #: Which catalogue to use by default (see :mod:`beefy_player.providers`). + provider: str = "somafm" #: Publish what is playing to the desktop media session (MPRIS on Linux, #: macOS Now Playing via mpv) and honour the media keys. The JSON key is #: spelled ``mpris`` for backwards compatibility. @@ -79,6 +127,7 @@ class Config: @classmethod def load(cls) -> Config: + migrate_legacy_state() raw = read_json(CONFIG_PATH) config = cls() if not isinstance(raw, dict): @@ -100,6 +149,10 @@ class Config: if visualizer in VISUALIZERS: config.visualizer = visualizer + provider = raw.get("provider") + if isinstance(provider, str) and provider.strip(): + config.provider = provider.strip().lower() + if isinstance(raw.get("autoplay"), bool): config.autoplay = raw["autoplay"] if isinstance(raw.get("mpris"), bool): diff --git a/somafm_tui/macos_tap.py b/beefy_player/macos_tap.py similarity index 99% rename from somafm_tui/macos_tap.py rename to beefy_player/macos_tap.py index 803312c..08ec58d 100644 --- a/somafm_tui/macos_tap.py +++ b/beefy_player/macos_tap.py @@ -407,7 +407,7 @@ class SystemAudioTap: if not nsuuid: raise TapError("could not create the tap UUID") _send_object(description, b"setUUID:", nsuuid) - _send_object(description, b"setName:", cfstr("somafm-tui")) + _send_object(description, b"setName:", cfstr("beefy-player")) _send_flag(description, b"setPrivate:", True) # 0 == CATapUnmuted: the audio we record keeps playing as usual. _send_integer(description, b"setMuteBehavior:", 0) @@ -460,7 +460,7 @@ class SystemAudioTap: owned.append(aggregate) for key, value in ( (_AGGREGATE_UID, cfstr(str(uuid.uuid4()).upper())), - (_AGGREGATE_NAME, cfstr("somafm-tui tap")), + (_AGGREGATE_NAME, cfstr("beefy-player tap")), (_AGGREGATE_PRIVATE, _boolean(True)), (_AGGREGATE_STACKED, _boolean(False)), (_AGGREGATE_TAPS, tap_list), diff --git a/somafm_tui/mediasession.py b/beefy_player/mediasession.py similarity index 93% rename from somafm_tui/mediasession.py rename to beefy_player/mediasession.py index 9e38f21..ec152a6 100644 --- a/somafm_tui/mediasession.py +++ b/beefy_player/mediasession.py @@ -10,15 +10,15 @@ The rest of the app only ever talks to this small surface:: which keeps the platform differences in one place: -* **Linux / BSD / any D-Bus desktop** — :class:`somafm_tui.mpris.MprisService` - exports ``org.mpris.MediaPlayer2.somafmtui.instance`` on the session +* **Linux / BSD / any D-Bus desktop** — :class:`beefy_player.mpris.MprisService` + exports ``org.mpris.MediaPlayer2.beefyplayer.instance`` on the session bus, so GNOME/KDE widgets, lock screens and ``playerctl`` show the station and can drive playback. * **macOS** — mpv *is* the app the system knows about: its Cocoa backend registers with ``MPRemoteCommandCenter``/``MPNowPlayingInfoCenter``, which is what Control Center, the menu bar and the media keys talk to. So there is nothing to publish here, and the media keys are routed back into the TUI - by mpv (see :mod:`somafm_tui.player`). :class:`NowPlayingSession` only + by mpv (see :mod:`beefy_player.player`). :class:`NowPlayingSession` only keeps the state the status bar renders and stays out of the way. Anything else (or a disabled/absent backend) degrades to an inert session @@ -114,7 +114,7 @@ class NowPlayingSession(MediaSession): """macOS: mpv owns the system Now Playing session, we just mirror state. mpv's Cocoa backend publishes the live ICY title (and play/pause state) to - Control Center and receives the media keys; :mod:`somafm_tui.player` routes + Control Center and receives the media keys; :mod:`beefy_player.player` routes those key presses back into this app. ``available`` is false when the audio backend is not mpv, because then nobody publishes anything. """ diff --git a/somafm_tui/mpris.py b/beefy_player/mpris.py similarity index 95% rename from somafm_tui/mpris.py rename to beefy_player/mpris.py index d5721c7..d58a356 100644 --- a/somafm_tui/mpris.py +++ b/beefy_player/mpris.py @@ -104,10 +104,12 @@ def build_metadata( (track_key or f"{station}|{artist}|{title}").encode("utf-8") ).hexdigest()[:24] metadata: dict[str, Any] = { - "mpris:trackid": Variant("o", f"/org/somafm/track/{digest}"), - "xesam:title": Variant("s", title or station or "SomaFM"), - "xesam:artist": Variant("as", [artist] if artist else ["SomaFM"]), - "xesam:comment": Variant("as", [f"SomaFM · {station}"] if station else ["SomaFM"]), + "mpris:trackid": Variant("o", f"/org/beefyplayer/track/{digest}"), + "xesam:title": Variant("s", title or station or "Beefy Player"), + "xesam:artist": Variant("as", [artist] if artist else ["Beefy Player"]), + "xesam:comment": Variant( + "as", [f"Beefy Player · {station}"] if station else ["Beefy Player"] + ), } if album: metadata["xesam:album"] = Variant("s", album) @@ -125,11 +127,11 @@ class _RootInterface(ServiceInterface): @dbus_property(name="Identity", access=PropertyAccess.READ) def Identity(self) -> "s": - return "SomaFM TUI" + return "Beefy Player" @dbus_property(name="DesktopEntry", access=PropertyAccess.READ) def DesktopEntry(self) -> "s": - return "somafm-tui" + return "beefy-player" @dbus_property(name="CanQuit", access=PropertyAccess.READ) def CanQuit(self) -> "b": @@ -284,7 +286,7 @@ class _PlayerInterface(ServiceInterface): class MprisService: - #: Status-bar name for this backend (see :mod:`somafm_tui.mediasession`). + #: Status-bar name for this backend (see :mod:`beefy_player.mediasession`). label = "mpris" def __init__( @@ -306,7 +308,7 @@ class MprisService: self._status = "Stopped" self._metadata: dict[str, Any] = {} self.bus_name = ( - f"org.mpris.MediaPlayer2.somafmtui.instance{os.getpid()}" + f"org.mpris.MediaPlayer2.beefyplayer.instance{os.getpid()}" ) # ------------------------------------------------------------------ # @@ -317,7 +319,7 @@ class MprisService: if not self.enabled: return self._thread = threading.Thread( - target=self._thread_main, name="somafm-mpris", daemon=True + target=self._thread_main, name="beefy-player-mpris", daemon=True ) self._thread.start() diff --git a/somafm_tui/player.py b/beefy_player/player.py similarity index 97% rename from somafm_tui/player.py rename to beefy_player/player.py index 9da7a31..a8bd5f4 100644 --- a/somafm_tui/player.py +++ b/beefy_player/player.py @@ -28,8 +28,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Any -from .api import USER_AGENT -from .config import RUNTIME_DIR, atomic_write_text +from .config import RUNTIME_DIR, USER_AGENT, atomic_write_text, env_var #: mpv input bindings that turn the system media keys into TUI commands. #: @@ -39,20 +38,20 @@ from .config import RUNTIME_DIR, atomic_write_text #: list, the spectrum and the desktop "now playing" state) we bounce them back #: over the IPC socket as ``client-message`` events. MEDIA_KEY_BINDINGS = """\ -# Generated by somafm-tui -- do not edit; it is rewritten on every launch. -PLAY script-message somafm toggle -PLAYONLY script-message somafm play -PAUSEONLY script-message somafm pause -STOP script-message somafm stop -NEXT script-message somafm next -PREV script-message somafm prev +# Generated by beefy-player -- do not edit; it is rewritten on every launch. +PLAY script-message beefy toggle +PLAYONLY script-message beefy play +PAUSEONLY script-message beefy pause +STOP script-message beefy stop +NEXT script-message beefy next +PREV script-message beefy prev """ #: Commands a media key may ask for; everything else is ignored. MEDIA_COMMANDS = frozenset({"play", "pause", "toggle", "stop", "next", "prev"}) #: Prefix of the ``client-message`` namespace we listen on. -MEDIA_MESSAGE_PREFIX = "somafm" +MEDIA_MESSAGE_PREFIX = "beefy" @dataclass @@ -113,7 +112,7 @@ class Player: ) return self._thread = threading.Thread( - target=self._thread_main, name="somafm-player", daemon=True + target=self._thread_main, name="beefy-player", daemon=True ) self._thread.start() self._ready.wait(10) @@ -316,12 +315,12 @@ class Player: "--cache=yes", "--cache-secs=20", "--demuxer-max-bytes=32MiB", - "--audio-client-name=somafm-tui", + "--audio-client-name=beefy-player", f"--user-agent={USER_AGENT}", "--network-timeout=20", ] command.extend(self._platform_options()) - extra = os.environ.get("SOMAFM_MPV_ARGS") + extra = env_var("MPV_ARGS") if extra: command.extend(shlex.split(extra)) @@ -466,7 +465,7 @@ class Player: self.events.put(PlayerEvent(kind="notice", detail=str(error))) def _handle_client_message(self, args: Any) -> None: - """Turn ``script-message somafm `` into a player event. + """Turn ``script-message beefy `` into a player event. mpv's macOS backend feeds the system media keys (and Control Center buttons) into its input system; ``MEDIA_KEY_BINDINGS`` maps them onto diff --git a/somafm_tui/api.py b/beefy_player/providers/somafm/api.py similarity index 100% rename from somafm_tui/api.py rename to beefy_player/providers/somafm/api.py diff --git a/somafm_tui/fallback.py b/beefy_player/providers/somafm/fallback.py similarity index 100% rename from somafm_tui/fallback.py rename to beefy_player/providers/somafm/fallback.py diff --git a/somafm_tui/spectrum.py b/beefy_player/spectrum.py similarity index 99% rename from somafm_tui/spectrum.py rename to beefy_player/spectrum.py index a7fdc95..4c39ef5 100644 --- a/somafm_tui/spectrum.py +++ b/beefy_player/spectrum.py @@ -7,9 +7,9 @@ leaves the speakers (status ``SYNC``). That capture is platform specific: * **Linux** — ``parec`` records the PulseAudio/PipeWire monitor of the default - sink (or ``SOMAFM_SPECTRUM_DEVICE``). + sink (or ``BEEFY_SPECTRUM_DEVICE``). * **macOS** — a native Core Audio *process tap* records the default output - device (see :mod:`somafm_tui.macos_tap`). Nothing to install: no virtual + device (see :mod:`beefy_player.macos_tap`). Nothing to install: no virtual loopback, no Multi-Output Device, no helper binary. macOS asks for **System Audio Recording** permission the first time; while it is missing the tap delivers silence, which is treated as a failed capture. @@ -35,7 +35,7 @@ from dataclasses import dataclass import numpy as np from . import macos_tap -from .api import USER_AGENT +from .config import USER_AGENT, env_var SAMPLE_RATE = 44100 FFT_SIZE = 2048 @@ -164,7 +164,7 @@ class SpectrumEngine: if self._thread is not None: return self._thread = threading.Thread( - target=self._run, name="somafm-spectrum", daemon=True + target=self._run, name="beefy-player-spectrum", daemon=True ) self._thread.start() @@ -315,7 +315,7 @@ class SpectrumEngine: return None def _monitor_device(self) -> str: - override = os.environ.get("SOMAFM_SPECTRUM_DEVICE", "").strip() + override = env_var("SPECTRUM_DEVICE") if override: return override if self._monitor_device_cache is not None: @@ -351,7 +351,7 @@ class SpectrumEngine: "--channels=1", "--raw", "--latency-msec=30", - "--client-name=somafm-tui", + "--client-name=beefy-player", ] def _spawn( diff --git a/somafm_tui/theme.tcss b/beefy_player/theme.tcss similarity index 97% rename from somafm_tui/theme.tcss rename to beefy_player/theme.tcss index 42c2253..a4d1076 100644 --- a/somafm_tui/theme.tcss +++ b/beefy_player/theme.tcss @@ -1,4 +1,4 @@ -/* somafm-tui — stock terminal palette, transparent backgrounds. +/* beefy-player — stock terminal palette, transparent backgrounds. Colours are ANSI names only; this file never paints a background. */ Screen { diff --git a/beefy_player/widgets/__init__.py b/beefy_player/widgets/__init__.py new file mode 100644 index 0000000..dc05ed6 --- /dev/null +++ b/beefy_player/widgets/__init__.py @@ -0,0 +1 @@ +"""Custom Textual widgets for Beefy Player.""" diff --git a/somafm_tui/widgets/art.py b/beefy_player/widgets/art.py similarity index 98% rename from somafm_tui/widgets/art.py rename to beefy_player/widgets/art.py index 7d9419e..237344a 100644 --- a/somafm_tui/widgets/art.py +++ b/beefy_player/widgets/art.py @@ -28,7 +28,7 @@ _NULL_STYLE = Style() _NULL_CONTROL = ((ControlType.CURSOR_FORWARD, 0),) if TYPE_CHECKING: - from ..app import SomaFMApp + from ..app import BeefyPlayerApp _IMAGE_IDS = itertools.count(random.randrange(1, 2**31 - 1)) @@ -314,7 +314,7 @@ class ArtPane(Widget): # ------------------------------------------------------------------ # def render(self) -> RenderableType: - app = cast("SomaFMApp", self.app) + app = cast("BeefyPlayerApp", self.app) station = app.current if station is None: return Text("") @@ -341,7 +341,7 @@ class ArtPane(Widget): # Keep the mosaic behind the image in sync with the current track. self.refresh() - app = cast("SomaFMApp", self.app) + app = cast("BeefyPlayerApp", self.app) station = app.current data = app.artwork_bytes width = max(1, self.size.width) diff --git a/somafm_tui/widgets/common.py b/beefy_player/widgets/common.py similarity index 100% rename from somafm_tui/widgets/common.py rename to beefy_player/widgets/common.py diff --git a/somafm_tui/widgets/header.py b/beefy_player/widgets/header.py similarity index 97% rename from somafm_tui/widgets/header.py rename to beefy_player/widgets/header.py index 92527be..b051ba8 100644 --- a/somafm_tui/widgets/header.py +++ b/beefy_player/widgets/header.py @@ -19,7 +19,7 @@ from .common import ( ) if TYPE_CHECKING: - from ..app import SomaFMApp + from ..app import BeefyPlayerApp LOGO = ( "▄▀▀▀ ▄▀▀▄ █▄ ▄█ ▄▀▀▄", @@ -34,7 +34,7 @@ class HeaderBar(Widget): can_focus = False def render(self) -> RenderableType: - app = cast("SomaFMApp", self.app) + app = cast("BeefyPlayerApp", self.app) width = max(24, self.size.width) height = max(1, self.size.height) state = app.playing_state diff --git a/somafm_tui/widgets/help.py b/beefy_player/widgets/help.py similarity index 100% rename from somafm_tui/widgets/help.py rename to beefy_player/widgets/help.py diff --git a/somafm_tui/widgets/search.py b/beefy_player/widgets/search.py similarity index 100% rename from somafm_tui/widgets/search.py rename to beefy_player/widgets/search.py diff --git a/somafm_tui/widgets/spectrum.py b/beefy_player/widgets/spectrum.py similarity index 99% rename from somafm_tui/widgets/spectrum.py rename to beefy_player/widgets/spectrum.py index 710f701..5ea34b9 100644 --- a/somafm_tui/widgets/spectrum.py +++ b/beefy_player/widgets/spectrum.py @@ -15,7 +15,7 @@ from ..spectrum import Frame, SpectrumEngine from .common import LEVELS, SPECTRUM_STOPS, gradient if TYPE_CHECKING: - from ..app import SomaFMApp + from ..app import BeefyPlayerApp #: One waterfall column per analyser tick; a deque this long covers ~25 s of #: history at 20 Hz, plenty for even a very wide terminal. @@ -62,7 +62,7 @@ class SpectrumPanel(Widget): self.refresh() def render(self) -> RenderableType: - app = cast("SomaFMApp", self.app) + app = cast("BeefyPlayerApp", self.app) frame = self.engine.snapshot() width = max(8, self.size.width) height = max(2, self.size.height) diff --git a/somafm_tui/widgets/stage.py b/beefy_player/widgets/stage.py similarity index 100% rename from somafm_tui/widgets/stage.py rename to beefy_player/widgets/stage.py diff --git a/somafm_tui/widgets/station_list.py b/beefy_player/widgets/station_list.py similarity index 94% rename from somafm_tui/widgets/station_list.py rename to beefy_player/widgets/station_list.py index 9d49feb..214a820 100644 --- a/somafm_tui/widgets/station_list.py +++ b/beefy_player/widgets/station_list.py @@ -12,7 +12,7 @@ from textual.widget import Widget from .common import compact_count, truncate if TYPE_CHECKING: - from ..app import SomaFMApp + from ..app import BeefyPlayerApp class StationList(Widget): @@ -29,13 +29,13 @@ class StationList(Widget): self.border_title = title def on_mouse_scroll_down(self, event) -> None: - cast("SomaFMApp", self.app).action_cursor_down() + cast("BeefyPlayerApp", self.app).action_cursor_down() def on_mouse_scroll_up(self, event) -> None: - cast("SomaFMApp", self.app).action_cursor_up() + cast("BeefyPlayerApp", self.app).action_cursor_up() def render(self) -> RenderableType: - app = cast("SomaFMApp", self.app) + app = cast("BeefyPlayerApp", self.app) width = max(12, self.size.width) height = max(1, self.size.height) view = app.view diff --git a/somafm_tui/widgets/status.py b/beefy_player/widgets/status.py similarity index 97% rename from somafm_tui/widgets/status.py rename to beefy_player/widgets/status.py index 8fa9ab1..009462c 100644 --- a/somafm_tui/widgets/status.py +++ b/beefy_player/widgets/status.py @@ -12,7 +12,7 @@ from textual.widget import Widget from .common import compact_count, state_glyph, truncate if TYPE_CHECKING: - from ..app import SomaFMApp + from ..app import BeefyPlayerApp HINTS_LONG = ( "SPACE pause ↑↓ station ENTER tune +/- vol f ★ / filter " @@ -28,7 +28,7 @@ class StatusBar(Widget): can_focus = False def render(self) -> RenderableType: - app = cast("SomaFMApp", self.app) + app = cast("BeefyPlayerApp", self.app) width = max(20, self.size.width) glyph, colour = state_glyph(app.playing_state) frame = app.spectrum.snapshot() diff --git a/pyproject.toml b/pyproject.toml index b8b0642..739340e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [project] -name = "somafm-tui" +name = "beefy-player" version = "0.1.0" -description = "A flashy terminal radio for SomaFM with a live spectrum analyser, MPRIS / macOS Now Playing support and song history" +description = "A flashy terminal player for internet radio — live spectrum analyser, MPRIS / macOS Now Playing support, song history and pluggable station catalogues (SomaFM included)" readme = "README.md" license = "WTFPL" license-files = ["LICENSE"] requires-python = ">=3.10" -authors = [{ name = "somafm-tui contributors" }] -keywords = ["somafm", "radio", "tui", "terminal", "mpris", "audio"] +authors = [{ name = "beefy-player contributors" }] +keywords = ["radio", "internet-radio", "tui", "terminal", "mpris", "audio", "somafm"] classifiers = [ "Environment :: Console", "Intended Audience :: End Users/Desktop", @@ -31,17 +31,17 @@ dependencies = [ ] [project.scripts] -somafm-tui = "somafm_tui.__main__:main" +beefy-player = "beefy_player.__main__:main" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["somafm_tui"] +packages = ["beefy_player"] [tool.hatch.build.targets.wheel.force-include] -"somafm_tui/theme.tcss" = "somafm_tui/theme.tcss" +"beefy_player/theme.tcss" = "beefy_player/theme.tcss" [dependency-groups] dev = [ @@ -60,8 +60,8 @@ extend-exclude = ["dist", ".venv", "build"] [tool.ruff.lint] select = ["E4", "E7", "E9", "F", "I", "UP", "B", "SIM", "RUF"] -ignore = ["RUF001"] # en/em dashes are part of real SomaFM metadata +ignore = ["RUF001"] # en/em dashes are part of real station metadata # dbus-next needs short signature strings ("s", "a{sv}", "x", …) in # annotations; they are not Python names. [tool.ruff.lint.per-file-ignores] -"somafm_tui/mpris.py" = ["F821", "F722", "UP037"] +"beefy_player/mpris.py" = ["F821", "F722", "UP037"] diff --git a/shell.nix b/shell.nix index d095bdb..d9ebd3b 100644 --- a/shell.nix +++ b/shell.nix @@ -1,6 +1,6 @@ { pkgs ? import { } }: -# FHS development shell for somafm-tui. +# FHS development shell for beefy-player. # # Why FHS? uv installs manylinux wheels from PyPI (notably NumPy) that expect # libstdc++.so.6 and friends in the usual FHS locations (/usr/lib, /lib). @@ -12,13 +12,13 @@ # nix-shell # enter the FHS shell # rm -rf .venv # once, if the venv was created by an older setup # uv sync # install Python deps with uv -# uv run somafm-tui # start the radio +# uv run beefy-player # start the radio # # Note: `nix-shell --run "..."` is not supported by FHS shell.nix files; enter # the shell interactively and run commands there. (pkgs.buildFHSEnv { - name = "somafm-tui"; + name = "beefy-player"; targetPkgs = pkgs: with pkgs; [ # --- Python toolchain; uv owns all Python dependencies --------------- @@ -83,13 +83,13 @@ export DBUS_SESSION_BUS_ADDRESS="unix:path=$XDG_RUNTIME_DIR/bus" fi - export PS1='\[\e[1;35m\][somafm-tui]\[\e[0m\] \w \$ ' + export PS1='\[\e[1;35m\][beefy-player]\[\e[0m\] \w \$ ' echo "" - echo " somafm-tui — FHS dev shell" - echo " --------------------------" + echo " beefy-player — FHS dev shell" + echo " ----------------------------" echo " uv sync install / update python deps" - echo " uv run somafm-tui start the radio" + echo " uv run beefy-player start the radio" echo "" echo " python : $(python3 --version 2>&1)" echo " uv : $(uv --version 2>&1)" diff --git a/somafm_tui/__init__.py b/somafm_tui/__init__.py deleted file mode 100644 index f5f3989..0000000 --- a/somafm_tui/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""A flashy terminal radio player for somafm.com.""" - -__version__ = "0.1.0" diff --git a/somafm_tui/widgets/__init__.py b/somafm_tui/widgets/__init__.py deleted file mode 100644 index fe45433..0000000 --- a/somafm_tui/widgets/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Custom Textual widgets for somafm-tui.""" diff --git a/tests/test_art.py b/tests/test_art.py index 7f3df3e..b957e50 100644 --- a/tests/test_art.py +++ b/tests/test_art.py @@ -3,11 +3,11 @@ from __future__ import annotations import pytest from PIL import Image -from somafm_tui import artwork -from somafm_tui.app import SomaFMApp -from somafm_tui.widgets import art as art_widgets -from somafm_tui.widgets.art import ArtPane, ITerm2Image, KittyImage -from somafm_tui.widgets.stage import StagePanel +from beefy_player import artwork +from beefy_player.app import BeefyPlayerApp +from beefy_player.widgets import art as art_widgets +from beefy_player.widgets.art import ArtPane, ITerm2Image, KittyImage +from beefy_player.widgets.stage import StagePanel async def _no_fetch(client, url: str) -> None: @@ -27,7 +27,7 @@ async def test_art_pane_switches_between_image_and_mosaic(monkeypatch) -> None: monkeypatch.setattr(artwork, "load_cached", lambda url: None) monkeypatch.setattr(artwork, "fetch", _no_fetch) - app = SomaFMApp(autoplay=False, offline=True, audio=False) + app = BeefyPlayerApp(autoplay=False, offline=True, audio=False) async with app.run_test(size=(110, 40)) as pilot: await pilot.pause() pane = app.query_one(ArtPane) diff --git a/tests/test_artwork.py b/tests/test_artwork.py index a057504..ff0fc4e 100644 --- a/tests/test_artwork.py +++ b/tests/test_artwork.py @@ -6,7 +6,7 @@ import httpx import pytest from PIL import Image -from somafm_tui import artwork +from beefy_player import artwork def png_bytes(size=(400, 200)) -> bytes: @@ -28,9 +28,11 @@ def test_protocol_detection() -> None: assert artwork.detect_protocol({"ZELLIJ": "1", "TERM_PROGRAM": "WezTerm"}) is None assert artwork.detect_protocol({"KITTY_PID": "1"}) == "tgp" assert artwork.detect_protocol({"NO_COLOR": "", "KITTY_WINDOW_ID": "1"}) is None - assert artwork.detect_protocol({"SOMAFM_IMAGES": "0", "KITTY_WINDOW_ID": "1"}) is None - assert artwork.detect_protocol({"SOMAFM_IMAGES": "tgp"}) == "tgp" - assert artwork.detect_protocol({"SOMAFM_IMAGES": "iterm2"}) == "iterm2" + assert artwork.detect_protocol({"BEEFY_IMAGES": "0", "KITTY_WINDOW_ID": "1"}) is None + assert artwork.detect_protocol({"BEEFY_IMAGES": "tgp"}) == "tgp" + assert artwork.detect_protocol({"BEEFY_IMAGES": "iterm2"}) == "iterm2" + # the pre-rename variable still works + assert artwork.detect_protocol({"SOMAFM_IMAGES": "0"}) is None def test_fit_image_crops_to_the_cell_box() -> None: diff --git a/tests/test_bandcamp.py b/tests/test_bandcamp.py index d795ce9..11d547b 100644 --- a/tests/test_bandcamp.py +++ b/tests/test_bandcamp.py @@ -5,7 +5,7 @@ import json import httpx import pytest -from somafm_tui import bandcamp +from beefy_player import bandcamp TRACK_PAYLOAD = { "auto": { diff --git a/tests/test_header.py b/tests/test_header.py index 22e01f7..0a3d124 100644 --- a/tests/test_header.py +++ b/tests/test_header.py @@ -6,7 +6,7 @@ import types import pytest from rich.cells import cell_len -from somafm_tui.widgets.header import HeaderBar +from beefy_player.widgets.header import HeaderBar TIME = re.compile(r"\d\d:\d\d:\d\d$") DATE = re.compile(r"[A-Z][a-z]{2} \d\d [A-Z][a-z]{2}$") diff --git a/tests/test_macos_tap.py b/tests/test_macos_tap.py index 60b2dc8..ed92efb 100644 --- a/tests/test_macos_tap.py +++ b/tests/test_macos_tap.py @@ -2,7 +2,7 @@ from __future__ import annotations import pytest -from somafm_tui import macos_tap +from beefy_player import macos_tap def test_osstatus_decodes_four_character_codes() -> None: diff --git a/tests/test_mediasession.py b/tests/test_mediasession.py index f5fede1..f376847 100644 --- a/tests/test_mediasession.py +++ b/tests/test_mediasession.py @@ -3,8 +3,8 @@ from __future__ import annotations import queue import sys -from somafm_tui import mediasession -from somafm_tui.mediasession import ( +from beefy_player import mediasession +from beefy_player.mediasession import ( MediaSession, NowPlayingSession, create_media_session, @@ -12,7 +12,7 @@ from somafm_tui.mediasession import ( def test_linux_uses_mpris() -> None: - from somafm_tui.mpris import MprisService + from beefy_player.mpris import MprisService session = create_media_session(queue.Queue(), enabled=False, platform="linux") assert isinstance(session, MprisService) diff --git a/tests/test_mpris.py b/tests/test_mpris.py index 3213114..73ec64e 100644 --- a/tests/test_mpris.py +++ b/tests/test_mpris.py @@ -4,7 +4,7 @@ import queue import pytest -from somafm_tui import mpris +from beefy_player import mpris def test_build_metadata() -> None: @@ -17,16 +17,16 @@ def test_build_metadata() -> None: stream_url="https://ice2.somafm.com/groovesalad-256-mp3", track_key="groovesalad|Jens Buchert|Rauschebart|Gravity", ) - assert metadata["mpris:trackid"].value.startswith("/org/somafm/track/") + assert metadata["mpris:trackid"].value.startswith("/org/beefyplayer/track/") assert metadata["xesam:title"].value == "Rauschebart" assert metadata["xesam:artist"].value == ["Jens Buchert"] assert metadata["xesam:album"].value == "Gravity" assert metadata["mpris:artUrl"].value == "https://example.com/art.jpg" assert metadata["xesam:url"].value.startswith("https://ice2.") - assert metadata["xesam:comment"].value == ["SomaFM · Groove Salad"] + assert metadata["xesam:comment"].value == ["Beefy Player · Groove Salad"] minimal = mpris.build_metadata(station="X", title="", artist="") - assert minimal["xesam:artist"].value == ["SomaFM"] + assert minimal["xesam:artist"].value == ["Beefy Player"] assert "xesam:album" not in minimal assert "mpris:artUrl" not in minimal diff --git a/tests/test_player.py b/tests/test_player.py index b6a749e..1ffbcc6 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -5,7 +5,7 @@ import queue import time from pathlib import Path -from somafm_tui.player import Player +from beefy_player.player import Player FAKE_MPV = '''#!/usr/bin/env python3 import json, os, socket, sys, time @@ -171,27 +171,27 @@ def test_backend_detection_without_tools(tmp_path, monkeypatch) -> None: def test_media_key_messages_become_player_events() -> None: player = Player() player._handle_mpv_message( - {"event": "client-message", "args": ["somafm", "next"]} + {"event": "client-message", "args": ["beefy", "next"]} ) event = player.events.get_nowait() assert event.kind == "media" assert event.detail == "next" player._handle_mpv_message( - {"event": "client-message", "args": ["somafm", "toggle"]} + {"event": "client-message", "args": ["beefy", "toggle"]} ) assert player.events.get_nowait().detail == "toggle" # Foreign namespaces, unknown commands and malformed payloads are ignored. player._handle_mpv_message({"event": "client-message", "args": ["other", "next"]}) - player._handle_mpv_message({"event": "client-message", "args": ["somafm", "seek"]}) + player._handle_mpv_message({"event": "client-message", "args": ["beefy", "seek"]}) player._handle_mpv_message({"event": "client-message", "args": None}) player._handle_mpv_message({"event": "client-message"}) assert player.events.empty() def test_platform_options_never_leak_to_linux(tmp_path, monkeypatch) -> None: - import somafm_tui.player as player_module + import beefy_player.player as player_module monkeypatch.setattr(player_module, "RUNTIME_DIR", tmp_path) assert Player()._platform_options("linux") == [] @@ -203,12 +203,12 @@ def test_platform_options_never_leak_to_linux(tmp_path, monkeypatch) -> None: assert conf.endswith("mpv-input.conf") bindings = (tmp_path / "mpv-input.conf").read_text() for line in ( - "PLAY script-message somafm toggle", - "PLAYONLY script-message somafm play", - "PAUSEONLY script-message somafm pause", - "STOP script-message somafm stop", - "NEXT script-message somafm next", - "PREV script-message somafm prev", + "PLAY script-message beefy toggle", + "PLAYONLY script-message beefy play", + "PAUSEONLY script-message beefy pause", + "STOP script-message beefy stop", + "NEXT script-message beefy next", + "PREV script-message beefy prev", ): assert line in bindings diff --git a/tests/test_spectrum.py b/tests/test_spectrum.py index b742b39..507796a 100644 --- a/tests/test_spectrum.py +++ b/tests/test_spectrum.py @@ -5,8 +5,8 @@ from pathlib import Path import numpy as np -from somafm_tui import spectrum as spectrum_module -from somafm_tui.spectrum import Frame, SpectrumEngine +from beefy_player import spectrum as spectrum_module +from beefy_player.spectrum import Frame, SpectrumEngine PCM_SCRIPT = """#!/usr/bin/env python3 import math, struct, sys, time @@ -42,7 +42,7 @@ def tap_pcm( class FakeTap: - """Stand-in for ``somafm_tui.macos_tap.SystemAudioTap``.""" + """Stand-in for ``beefy_player.macos_tap.SystemAudioTap``.""" def __init__( self, diff --git a/tests/test_spectrum_panel.py b/tests/test_spectrum_panel.py index 46954af..80393e0 100644 --- a/tests/test_spectrum_panel.py +++ b/tests/test_spectrum_panel.py @@ -4,9 +4,9 @@ from __future__ import annotations import math -from somafm_tui import config -from somafm_tui.spectrum import Frame, SpectrumEngine -from somafm_tui.widgets.spectrum import ( +from beefy_player import config +from beefy_player.spectrum import Frame, SpectrumEngine +from beefy_player.widgets.spectrum import ( VISUALIZER_LABELS, WATERFALL_HISTORY, SpectrumPanel, diff --git a/tests/test_theme.py b/tests/test_theme.py index 282d57b..3490f33 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -5,15 +5,15 @@ from pathlib import Path import pytest -from somafm_tui.app import SomaFMApp -from somafm_tui.widgets.header import HeaderBar -from somafm_tui.widgets.search import SearchInput -from somafm_tui.widgets.spectrum import SpectrumPanel -from somafm_tui.widgets.stage import StagePanel -from somafm_tui.widgets.station_list import StationList -from somafm_tui.widgets.status import StatusBar +from beefy_player.app import BeefyPlayerApp +from beefy_player.widgets.header import HeaderBar +from beefy_player.widgets.search import SearchInput +from beefy_player.widgets.spectrum import SpectrumPanel +from beefy_player.widgets.stage import StagePanel +from beefy_player.widgets.station_list import StationList +from beefy_player.widgets.status import StatusBar -PACKAGE = Path(__file__).resolve().parents[1] / "somafm_tui" +PACKAGE = Path(__file__).resolve().parents[1] / "beefy_player" def test_no_rgb_colors_in_source() -> None: @@ -29,7 +29,7 @@ def test_no_rgb_colors_in_source() -> None: @pytest.mark.asyncio async def test_backgrounds_use_terminal_default_and_borders_are_ansi() -> None: - app = SomaFMApp(autoplay=False, offline=True, audio=False) + app = BeefyPlayerApp(autoplay=False, offline=True, audio=False) async with app.run_test(size=(100, 30)) as pilot: await pilot.pause() diff --git a/tests/test_timezone.py b/tests/test_timezone.py index de73917..f983712 100644 --- a/tests/test_timezone.py +++ b/tests/test_timezone.py @@ -5,7 +5,7 @@ from pathlib import Path import pytest -from somafm_tui.widgets import common +from beefy_player.widgets import common BERLIN = Path("/usr/share/zoneinfo/Europe/Berlin") UTC_FILE = Path("/usr/share/zoneinfo/UTC") diff --git a/uv.lock b/uv.lock index d30de94..e3db447 100644 --- a/uv.lock +++ b/uv.lock @@ -683,7 +683,7 @@ wheels = [ ] [[package]] -name = "somafm-tui" +name = "beefy-player" version = "0.1.0" source = { editable = "." } dependencies = [