refactor!: rename somafm-tui to beefy-player

The app is now a general internet-radio player rather than a SomaFM client, so the package, console script, MPRIS identity, mpv namespaces, macOS tap names, XDG directories and docs all move to beefy-player.

Environment variables are now BEEFY_* (the old SOMAFM_* spellings are still honoured) and an existing ~/.config/somafm-tui/config.json is migrated on first run, so favourites and volume survive the rename.

The visible interface is untouched: same layout, same logo, same keys.
This commit is contained in:
beefcarver committed 2026-11-03 16:10:25 -05:00
1 parent 4bed90b489
commit e30ca09627
44 files changed
+329 -192

No files matched your search

+463
View File
@@ -0,0 +1,463 @@
"""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]
+96
View File
@@ -0,0 +1,96 @@
"""Bundled station list.
Only used when somafm.com cannot be reached *and* no cache exists yet, so the
app is still useful on a plane. The live list from ``channels.json`` always
wins. Stream links are built from SomaFM's well-known ``.pls`` naming scheme
and are additionally resolved at runtime.
"""
from __future__ import annotations
from typing import Any
# (id, title, genre, description)
_CHANNELS: tuple[tuple[str, str, str, str], ...] = (
("groovesalad", "Groove Salad", "ambient|electronic", "A nicely chilled plate of ambient/downtempo beats and grooves."),
("dronezone", "Drone Zone", "ambient", "Served best chilled, safe with most medications. Atmospheric textures with minimal beats."),
("deepspaceone", "Deep Space One", "ambient", "Deep ambient electronic, experimental and space music. For inner and outer space exploration."),
("spacestation", "Space Station Soma", "electronic", "Tune in, turn on, space out. Spaced-out ambient and mid-tempo electronica."),
("missioncontrol", "Mission Control", "ambient|electronic", "Celebrating NASA and Space Explorers everywhere."),
("darkzone", "The Dark Zone", "ambient", "The darker side of deep ambient. Music for staring into the Abyss."),
("dz2", "Drone Zone 2", "ambient", "A more eclectic alternative mix of atmospheric textures with minimal beats."),
("synphaera", "Synphaera Radio", "ambient|electronic", "Featuring the music from an independent record label focused on modern electronic ambient and space music."),
("secretagent", "Secret Agent", "lounge", "The soundtrack for your stylish, mysterious, dangerous life. For Spies and PIs too!"),
("illstreet", "Illinois Street Lounge", "lounge", "Classic bachelor pad, playful exotica and vintage music of tomorrow."),
("tikitime", "Tiki Time", "tiki|world", "Classic Tiki music and Vintage island rhythms to sip cocktails by."),
("bossa", "Bossa Beyond", "bossanova|world", "Silky-smooth, laid-back Brazilian-style rhythms of Bossa Nova, Samba and beyond"),
("suburbsofgoa", "Suburbs of Goa", "world", "Desi-influenced Asian world beats and beyond."),
("thistle", "ThistleRadio", "celtic|world", "Exploring music from Celtic roots and branches"),
("indiepop", "Indie Pop Rocks!", "alternative|rock", "New and classic favorite indie pop tracks."),
("digitalis", "Digitalis", "electronic|alternative", "Digitally affected analog rock to calm the agitated heart."),
("poptron", "PopTron", "alternative", "Electropop and indie dance rock with sparkle and pop."),
("u80s", "Underground 80s", "alternative|electronic", "Early 80s UK Synthpop and a bit of New Wave."),
("seventies", "Left Coast 70s", "70s|rock", "Mellow album rock from the Seventies. Yacht not required."),
("reggae", "Heavyweight Reggae", "reggae", "Reggae, Ska, Rocksteady classic and deep tracks."),
("bootliquor", "Boot Liquor", "americana", "Americana Roots music for Cowhands, Cowpokes and Cowtippers"),
("folkfwd", "Folk Forward", "folk|alternative", "Indie Folk, Alt-folk and the occasional folk classics."),
("lush", "Lush", "electronic", "Sensuous and mellow female vocals, many with an electronic influence."),
("fluid", "Fluid", "electronic|hiphop", "Drown in the electronic sound of instrumental hiphop, future soul and liquid trap."),
("beatblender", "Beat Blender", "electronic", "A late night blend of deep-house and downtempo chill."),
("cliqhop", "cliqhop idm", "electronic", "Blips'n'beeps backed mostly w/beats. Intelligent Dance Music."),
("thetrip", "The Trip", "electronic", "Progressive house / trance. Tip top tunes."),
("defcon", "DEF CON Radio", "electronic|specials", "Music for Hacking. The DEF CON Year-Round Channel."),
("dubstep", "Dub Step Beyond", "electronic", "Dubstep, Dub and Deep Bass. May damage speakers at high volume."),
("vaporwaves", "Vaporwaves", "electronic", "All Vaporwave. All the time."),
("sonicuniverse", "Sonic Universe", "jazz", "Transcending the world of jazz with eclectic, avant-garde takes on tradition."),
("metal", "Metal Detector", "metal", "From black to doom, prog to sludge, thrash to post, stoner to crossover, punk to industrial."),
("doomed", "Doomed", "ambient|industrial", "Where every day is Halloween: Dark industrial/ambient music for tortured souls."),
("covers", "Covers", "eclectic", "Just covers. Songs you know by artists you don't. We've got you covered."),
("brfm", "Black Rock FM", "eclectic", "From the Black Rock Desert playa to the world, year round!"),
("7soul", "Seven Inch Soul", "oldies", "Vintage soul tracks from the original 45 RPM vinyl."),
("insound", "The In-Sound", "oldies", "60s/70s Hipster Euro Pop where psychedelic melodies meets groovy vibes."),
("sf1033", "SF 10-33", "ambient|news", "Ambient music mixed with the sounds of San Francisco public safety radio traffic."),
("scanner", "SF Police Scanner", "live|news", "San Francisco Public Safety Scanner Feed"),
("sfinsf", "SF in SF", "spoken", "Author readings and discussions from the science fiction, fantasy, horror, and genre literary fields."),
("live", "SomaFM Live", "live|specials", "Special Live Events and rebroadcasts of past live events"),
("specials", "SomaFM Specials", "specials", "Now featuring Afternoon Jazz, Wavepool, DubX, The Surf Report & More!"),
("n5md", "n5MD Radio", "specials", "Emotional Experiments in Music: Ambient, modern composition, post-rock, & experimental electronic music"),
("chillits", "Chillits Radio", "chill|live", "Celebrating 25 years of music, chilling and camping"),
("groovesalad2", "Groove Salad 2", "ambient|electronic", "A different mix of nicely chilled ambient/downtempo beats and grooves."),
("gsclassic", "Groove Salad Classic", "ambient|electronic", "The classic (early 2000s) version of a nicely chilled plate of ambient/downtempo beats and grooves."),
("christmas", "Christmas Lounge", "holiday", "Chilled holiday grooves and classic winter lounge tracks."),
("xmasrocks", "Christmas Rocks!", "holiday", "Have yourself an indie/alternative holiday season!"),
("jollysoul", "Jolly Ol' Soul", "holiday", "Where we cut right to the soul of the season."),
("deptstore", "Department Store Christmas", "holiday", "Holiday Elevator Music from a more innocent time."),
("xmasinfrisko", "Xmas in Frisko", "holiday", "SomaFM's wacky and eclectic holiday mix. Not for the easily offended."),
)
def _playlists(channel_id: str) -> list[dict[str, str]]:
return [
{"url": f"https://somafm.com/{channel_id}.pls", "format": "mp3", "quality": "highest"},
{"url": f"https://somafm.com/{channel_id}130.pls", "format": "aac", "quality": "highest"},
{"url": f"https://somafm.com/{channel_id}64.pls", "format": "aacp", "quality": "high"},
{"url": f"https://somafm.com/{channel_id}32.pls", "format": "aacp", "quality": "low"},
]
def fallback_channels() -> list[dict[str, Any]]:
"""Return a channels.json-shaped payload so the normal parser can eat it."""
payload: list[dict[str, Any]] = []
for channel_id, title, genre, description in _CHANNELS:
payload.append(
{
"id": channel_id,
"title": title,
"description": description,
"dj": "SomaFM",
"genre": genre,
"image": f"https://somafm.com/logos/256/{channel_id}256.png",
"largeimage": f"https://somafm.com/logos/512/{channel_id}512.png",
"playlists": _playlists(channel_id),
"listeners": "0",
"lastPlaying": "",
}
)
return payload