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:
1 parent
4bed90b489
commit
e30ca09627
44 files changed
+329
-192
No files matched your search
@@ -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]
|
||||
@@ -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
|
||||
Reference in new issue
Block a user