feat(providers): add nine catalogues and the Radio Browser directory

This commit is contained in:
beefcarver committed 2026-11-03 20:24:53 -05:00
1 parent a3ad693132
commit 49f7efe0d2
27 files changed
+1695 -22

No files matched your search

@@ -0,0 +1,136 @@
"""Radio Paradise: five hand-curated mixes, listener-supported since 2000.
Unlike the community stations, Radio Paradise publishes a proper now-playing
API for every mix, with album and cover art — so this provider is a small
catalogue with real track metadata rather than a single relay.
"""
from __future__ import annotations
import re
import time
from datetime import datetime, timezone
from pathlib import Path
import httpx
from ... import config
from ...models import Song, Station
from ..base import RadioProvider
from ..wordmark import render
from . import api
__all__ = ["RadioParadiseProvider"]
#: How many tracks LAST PLAYED remembers per mix.
HISTORY_LIMIT = 20
class RadioParadiseProvider(RadioProvider):
id = "radioparadise"
name = "Radio Paradise"
homepage = "radioparadise.com"
brand = "RADIO PARADISE"
tagline = "listener-supported · curated mixes"
station_source = "api.radioparadise.com/now_playing"
history_source = "api.radioparadise.com (rolling)"
hero = render("PARADISE")
hero_lines = ("RADIO PARADISE", "LISTENER SUPPORTED", "PARADISE, CALIFORNIA")
@property
def user_agent(self) -> str:
return f"{config.USER_AGENT}; +https://radioparadise.com"
def stations(self) -> list[Station]:
return [
Station(
id=station_id,
title=title,
description=f"Radio Paradise — {title}",
genre=genre,
playlists=api.playlists(names),
provider=self.id,
)
for station_id, _chan, title, genre, names in api.CHANNELS
]
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
return self.stations()
def load_cached_stations(self) -> list[Station]:
return self.stations()
def fallback_stations(self) -> list[Station]:
return self.stations()
def _songs_cache(self, station_id: str) -> Path:
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", station_id or self.id)
return config.SONGS_CACHE_DIR / f"{self.id}-{safe}.json"
async def fetch_history(
self, client: httpx.AsyncClient, station_id: str
) -> list[Song]:
chan = api.CHANNEL_BY_STATION.get(station_id)
if chan is None:
return self.load_cached_history(station_id)
current = await api.fetch_now(client, chan)
if current is None:
return self.load_cached_history(station_id)
history = self.load_cached_history(station_id)
if history and history[0].key() == current.key():
current.played_at = history[0].played_at
history = [current, *history[1:]]
else:
current.played_at = datetime.now(timezone.utc)
history = [current, *[song for song in history if song.key() != current.key()]]
history = history[:HISTORY_LIMIT]
config.write_json(
self._songs_cache(station_id),
{
"fetched": time.time(),
"songs": [
{
"title": song.title,
"artist": song.artist,
"album": song.album,
"art": song.art_url,
"played_at": song.played_at.isoformat()
if song.played_at
else "",
}
for song in history
],
},
)
return history
def load_cached_history(self, station_id: str) -> list[Song]:
raw = config.read_json(self._songs_cache(station_id))
entries = raw.get("songs") if isinstance(raw, dict) else None
if not isinstance(entries, list):
return []
songs: list[Song] = []
for entry in entries:
if not isinstance(entry, dict):
continue
song = Song(
title=str(entry.get("title") or "").strip(),
artist=str(entry.get("artist") or "").strip(),
album=str(entry.get("album") or "").strip(),
art_url=str(entry.get("art") or "").strip(),
played_at=None,
)
if isinstance(entry.get("played_at"), str) and entry["played_at"]:
from ...models import parse_date
song.played_at = parse_date(entry["played_at"])
if song.title or song.artist:
songs.append(song)
return songs
def fallback_urls(self, station: Station) -> list[str]:
return [entry["url"] for entry in station.sorted_playlists()]
@@ -0,0 +1,85 @@
"""Radio Paradise's public endpoints, and nothing but.
* ``https://api.radioparadise.com/api/now_playing?chan=N`` -> the current track
(artist, title, album, year, cover art, seconds remaining)
* ``https://stream.radioparadise.com/<name>`` -> the icecast relays
Radio Paradise curates five mixes; each is one relay and one channel number on
the now-playing API. The main mix carries lossy relays as well as FLAC; the
alternate mixes are FLAC only.
"""
from __future__ import annotations
from typing import Any
import httpx
from ...models import Song
from ..base import fetch_json
NOW_URL = "https://api.radioparadise.com/api/now_playing?chan={chan}"
STREAM_URL = "https://stream.radioparadise.com/{name}"
FORMAT_LABELS = {"flac": ("flac", "highest"), "aac-320": ("aac", "high")}
#: (station id, channel, title, genre, relay names)
CHANNELS: tuple[tuple[str, int, str, str, tuple[str, ...]], ...] = (
(
"main",
0,
"The Main Mix",
"eclectic|rock",
("flac", "aac-320", "mp3-320", "mp3-128"),
),
("mellow", 1, "Mellow Mix", "chill|eclectic", ("mellow-flac",)),
("rock", 2, "RockIt!", "rock|alternative", ("rock-flac",)),
("global", 3, "The Globe", "world|eclectic", ("global-flac",)),
("beyond", 5, "Beyond...", "ambient|experimental", ("beyond-flac",)),
)
CHANNEL_BY_STATION = {station_id: chan for station_id, chan, *_ in CHANNELS}
def parse_now(payload: Any) -> Song | None:
if not isinstance(payload, dict):
return None
artist = str(payload.get("artist") or "").strip()
title = str(payload.get("title") or "").strip()
if not (artist or title):
return None
return Song(
artist=artist,
title=title,
album=str(payload.get("album") or "").strip(),
art_url=str(payload.get("cover") or payload.get("cover_med") or "").strip(),
)
async def fetch_now(client: httpx.AsyncClient, chan: int) -> Song | None:
try:
payload = await fetch_json(client, NOW_URL.format(chan=chan), attempts=1)
except RuntimeError:
return None
return parse_now(payload)
def playlists(names: tuple[str, ...]) -> list[dict[str, str]]:
"""The relay list for one mix, best first."""
entries: list[dict[str, str]] = []
for name in names:
fmt, quality = FORMAT_LABELS.get(name, (name.split("-")[0], "high"))
bitrate = ""
if fmt == "flac":
bitrate = "1411" # nominal CD-quality FLAC rate
elif name.startswith(("aac-", "mp3-")):
bitrate = name.split("-", 1)[1]
entries.append(
{
"url": STREAM_URL.format(name=name),
"format": fmt,
"quality": quality,
"bitrate": bitrate,
}
)
return entries
@@ -0,0 +1,7 @@
"""Radio Paradise's header hero: the one word it is named after."""
from __future__ import annotations
from ..wordmark import render
HERO: tuple[str, ...] = render("PARADISE")