feat(providers): add nine catalogues and the Radio Browser directory
This commit is contained in:
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")
|
||||
Reference in new issue
Block a user