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
@@ -18,6 +18,18 @@ from __future__ import annotations
|
||||
|
||||
from .base import RadioProvider
|
||||
from .bassdrive import BassdriveProvider
|
||||
from .community import (
|
||||
AnonradioProvider,
|
||||
CashmereProvider,
|
||||
DatafruitsProvider,
|
||||
DublabProvider,
|
||||
LotProvider,
|
||||
LylProvider,
|
||||
RefugeProvider,
|
||||
RinseProvider,
|
||||
)
|
||||
from .radiobrowser import RadioBrowserProvider
|
||||
from .radioparadise import RadioParadiseProvider
|
||||
from .somafm import SomaFMProvider
|
||||
from .tilderadio import TilderadioProvider
|
||||
|
||||
@@ -29,10 +41,28 @@ __all__ = [
|
||||
"get_provider",
|
||||
]
|
||||
|
||||
#: Every catalogue the app can talk to, keyed by its slug.
|
||||
#: Every catalogue the app can talk to, keyed by its slug. The picker, the
|
||||
#: help screen and ``--list`` all read this order: alphabetical by name.
|
||||
PROVIDERS: dict[str, RadioProvider] = {
|
||||
provider.id: provider
|
||||
for provider in (SomaFMProvider(), TilderadioProvider(), BassdriveProvider())
|
||||
for provider in sorted(
|
||||
(
|
||||
SomaFMProvider(),
|
||||
TilderadioProvider(),
|
||||
BassdriveProvider(),
|
||||
RadioBrowserProvider(),
|
||||
RadioParadiseProvider(),
|
||||
DublabProvider(),
|
||||
RinseProvider(),
|
||||
RefugeProvider(),
|
||||
LylProvider(),
|
||||
LotProvider(),
|
||||
CashmereProvider(),
|
||||
DatafruitsProvider(),
|
||||
AnonradioProvider(),
|
||||
),
|
||||
key=lambda provider: provider.name.casefold(),
|
||||
)
|
||||
}
|
||||
|
||||
#: Provider used when nothing else is configured.
|
||||
|
||||
@@ -194,8 +194,9 @@ class RadioProvider(ABC):
|
||||
|
||||
|
||||
def is_playlist(url: str) -> bool:
|
||||
"""A playlist *we* resolve; HLS is handed to the player untouched."""
|
||||
path = url.split("?", 1)[0].split("#", 1)[0].lower()
|
||||
return path.endswith((".pls", ".m3u", ".m3u8"))
|
||||
return path.endswith((".pls", ".m3u"))
|
||||
|
||||
|
||||
def is_direct_stream(url: str) -> bool:
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Independent stations: one relay each, with the metadata the relay offers.
|
||||
|
||||
Each module here is a thin subclass of
|
||||
:class:`beefy_player.providers.community.base.CommunityProvider`, which knows
|
||||
how to read an Icecast status document or a radio.co status, keep a rolling
|
||||
history and (for Dublab) follow a published schedule.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .anonradio import AnonradioProvider
|
||||
from .cashmere import CashmereProvider
|
||||
from .datafruits import DatafruitsProvider
|
||||
from .dublab import DublabProvider
|
||||
from .lot import LotProvider
|
||||
from .lyl import LylProvider
|
||||
from .refuge import RefugeProvider
|
||||
from .rinse import RinseProvider
|
||||
|
||||
__all__ = [
|
||||
"AnonradioProvider",
|
||||
"CashmereProvider",
|
||||
"DatafruitsProvider",
|
||||
"DublabProvider",
|
||||
"LotProvider",
|
||||
"LylProvider",
|
||||
"RefugeProvider",
|
||||
"RinseProvider",
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
"""aNONradio: SDF's community station, on Icecast since the dial was young."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import CommunityProvider
|
||||
|
||||
|
||||
class AnonradioProvider(CommunityProvider):
|
||||
id = "anonradio"
|
||||
name = "aNONradio"
|
||||
homepage = "anonradio.net"
|
||||
brand = "aNONradio"
|
||||
tagline = "SDF community radio"
|
||||
station_source = "anonradio.net icecast"
|
||||
history_source = "icecast title (rolling)"
|
||||
|
||||
station_title = "aNONradio"
|
||||
station_description = (
|
||||
"community station of the SDF public-access UNIX system"
|
||||
)
|
||||
station_genre = "eclectic|talk"
|
||||
streams = (("https://anonradio.net:8443/anonradio", "mp3", "high", "192"),)
|
||||
status_url = "https://anonradio.net:8443/status-json.xsl"
|
||||
|
||||
wordmark = "ANONRADIO"
|
||||
hero_lines = ("SDF COMMUNITY RADIO", "PUBLIC ACCESS UNIX")
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Community stations: one relay each, sometimes with a status document.
|
||||
|
||||
Most independent stations are a single stream on Icecast, radio.co or Airtime,
|
||||
plus — if you are lucky — a status document that names what is on. This module
|
||||
turns that into a provider: the station, its relays, and a rolling history of
|
||||
whatever the relay has announced while you were listening.
|
||||
|
||||
A subclass only carries data. Stations with a schedule or a multi-station
|
||||
catalogue (Dublab, Rinse, Radio Paradise) override a hook or two.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from ... import config
|
||||
from ...models import Broadcast, Song, Station, parse_date, parse_last_playing, to_int
|
||||
from ..base import RadioProvider, fetch_json
|
||||
from ..wordmark import render
|
||||
|
||||
#: ``public.radio.co`` status document: keyless, no station id in the body.
|
||||
RADIO_CO_STATUS = "https://public.radio.co/stations/{station}/status"
|
||||
|
||||
#: How many shows/tracks LAST PLAYED remembers.
|
||||
HISTORY_LIMIT = 20
|
||||
|
||||
|
||||
class CommunityProvider(RadioProvider):
|
||||
"""A catalogue that broadcasts one stream (or a fixed handful)."""
|
||||
|
||||
# -- catalogue data (set by each station module) ---------------------- #
|
||||
|
||||
station_title = ""
|
||||
station_description = ""
|
||||
station_genre = "eclectic"
|
||||
station_image = ""
|
||||
|
||||
#: ``(url, format, quality, bitrate)`` — the relays, best first.
|
||||
streams: tuple[tuple[str, str, str, str], ...] = ()
|
||||
#: Keyless Icecast ``status-json.xsl``, when the relay publishes one.
|
||||
status_url = ""
|
||||
#: ``public.radio.co`` station id, when the relay is radio.co.
|
||||
radio_co_id = ""
|
||||
#: Wordmark the header spells; empty leaves the provider without a hero.
|
||||
wordmark = ""
|
||||
|
||||
# Independent stations are not Bandcamp catalogues.
|
||||
bandcamp = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
if self.wordmark:
|
||||
self.hero = render(self.wordmark)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# station
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _playlists(self) -> list[dict[str, str]]:
|
||||
return [
|
||||
{"url": url, "format": fmt, "quality": quality, "bitrate": bitrate}
|
||||
for url, fmt, quality, bitrate in self.streams
|
||||
]
|
||||
|
||||
def station(self) -> Station:
|
||||
return Station(
|
||||
id=self.id,
|
||||
title=self.station_title or self.name,
|
||||
description=self.station_description or self.tagline,
|
||||
genre=self.station_genre,
|
||||
image=self.station_image,
|
||||
playlists=self._playlists(),
|
||||
provider=self.id,
|
||||
)
|
||||
|
||||
def stations(self) -> list[Station]:
|
||||
return [self.station()]
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# what the relay says is on
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@property
|
||||
def has_status(self) -> bool:
|
||||
return bool(self.status_url or self.radio_co_id)
|
||||
|
||||
async def _status(self, client: httpx.AsyncClient, attempts: int = 2) -> Any:
|
||||
url = (
|
||||
RADIO_CO_STATUS.format(station=self.radio_co_id)
|
||||
if self.radio_co_id
|
||||
else self.status_url
|
||||
)
|
||||
return await fetch_json(client, url, attempts=attempts)
|
||||
|
||||
def _listeners(self, payload: Any) -> int:
|
||||
if not isinstance(payload, dict):
|
||||
return 0
|
||||
if self.radio_co_id:
|
||||
return to_int(payload.get("listeners"))
|
||||
stats = payload.get("icestats")
|
||||
source = stats.get("source") if isinstance(stats, dict) else None
|
||||
sources = (
|
||||
source
|
||||
if isinstance(source, list)
|
||||
else [source]
|
||||
if isinstance(source, dict)
|
||||
else []
|
||||
)
|
||||
return sum(
|
||||
to_int(item.get("listeners")) for item in sources if isinstance(item, dict)
|
||||
)
|
||||
|
||||
def _icecast_title(self, payload: Any) -> str:
|
||||
stats = payload.get("icestats") if isinstance(payload, dict) else None
|
||||
source = stats.get("source") if isinstance(stats, dict) else None
|
||||
sources = (
|
||||
source
|
||||
if isinstance(source, list)
|
||||
else [source]
|
||||
if isinstance(source, dict)
|
||||
else []
|
||||
)
|
||||
for item in sources:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
for key in ("display-title", "title", "yp_currently_playing"):
|
||||
value = item.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
def _now(self, payload: Any) -> Song | None:
|
||||
"""The relay's current track/show, as a :class:`Song`."""
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
if self.radio_co_id:
|
||||
track = payload.get("current_track")
|
||||
if not isinstance(track, dict):
|
||||
return None
|
||||
title = str(track.get("title") or "").strip()
|
||||
if not title:
|
||||
return None
|
||||
song = parse_last_playing(title) or Song(title=title)
|
||||
song.art_url = str(track.get("artwork_url") or "").strip()
|
||||
song.played_at = parse_date(track.get("start_time"))
|
||||
return song
|
||||
|
||||
title = self._icecast_title(payload)
|
||||
if not title:
|
||||
return None
|
||||
return parse_last_playing(title)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# the schedule, when the catalogue has one (Dublab)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _scheduled_now(self) -> Broadcast | None:
|
||||
now = datetime.now(timezone.utc)
|
||||
for entry in self.load_cached_schedule():
|
||||
if entry.start is None or entry.end is None:
|
||||
continue
|
||||
if entry.start <= now < entry.end:
|
||||
return entry
|
||||
return None
|
||||
|
||||
def _mark_live(self, entries: list[Broadcast]) -> list[Broadcast]:
|
||||
"""Flag the slot that is running now, so the pane can say ON AIR."""
|
||||
now = datetime.now(timezone.utc)
|
||||
marked: list[Broadcast] = []
|
||||
for entry in entries:
|
||||
if (
|
||||
entry.start is not None
|
||||
and entry.end is not None
|
||||
and entry.start <= now < entry.end
|
||||
):
|
||||
marked.append(
|
||||
Broadcast(
|
||||
name=entry.name,
|
||||
dj=entry.dj,
|
||||
location=entry.location,
|
||||
start=entry.start,
|
||||
end=entry.end,
|
||||
live=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
marked.append(entry)
|
||||
return marked
|
||||
|
||||
def _scheduled_song(self) -> Song | None:
|
||||
entry = self._scheduled_now()
|
||||
if entry is None:
|
||||
return None
|
||||
return Song(artist=self.name, title=entry.name)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# caching
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _station_cache(self) -> Path:
|
||||
return config.channels_cache_path(self.id)
|
||||
|
||||
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"
|
||||
|
||||
def _songs_to_cache(self, songs: list[Song]) -> list[dict[str, str]]:
|
||||
return [
|
||||
{
|
||||
"title": song.title,
|
||||
"artist": song.artist,
|
||||
"art": song.art_url,
|
||||
"played_at": song.played_at.isoformat() if song.played_at else "",
|
||||
}
|
||||
for song in songs
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# RadioProvider
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
|
||||
stations = self.stations()
|
||||
payload: Any = None
|
||||
if self.has_status:
|
||||
try:
|
||||
payload = await self._status(client)
|
||||
except RuntimeError:
|
||||
payload = None
|
||||
if payload is None:
|
||||
payload = self._cached_status()
|
||||
else:
|
||||
config.write_json(
|
||||
self._station_cache(), {"fetched": time.time(), "status": payload}
|
||||
)
|
||||
|
||||
song = self._now(payload) if payload is not None else None
|
||||
if song is None:
|
||||
song = self._scheduled_song()
|
||||
listeners = self._listeners(payload) if payload is not None else 0
|
||||
entry = self._scheduled_now()
|
||||
|
||||
for station in stations:
|
||||
if song is not None:
|
||||
station.now_playing = song
|
||||
station.last_playing = (
|
||||
f"{song.artist} - {song.title}" if song.artist else song.title
|
||||
)
|
||||
if listeners:
|
||||
station.listeners = listeners
|
||||
if entry is not None:
|
||||
station.presenter = (
|
||||
f"live from {entry.location}" if entry.location else "live now"
|
||||
)
|
||||
return stations
|
||||
|
||||
def _cached_status(self) -> Any:
|
||||
raw = config.read_json(self._station_cache())
|
||||
return raw.get("status") if isinstance(raw, dict) else None
|
||||
|
||||
def load_cached_stations(self) -> list[Station]:
|
||||
stations = self.stations()
|
||||
payload = self._cached_status()
|
||||
song = self._now(payload) if payload is not None else None
|
||||
if song is None:
|
||||
song = self._scheduled_song()
|
||||
listeners = self._listeners(payload) if payload is not None else 0
|
||||
for station in stations:
|
||||
if song is not None:
|
||||
station.now_playing = song
|
||||
if listeners:
|
||||
station.listeners = listeners
|
||||
return stations
|
||||
|
||||
def fallback_stations(self) -> list[Station]:
|
||||
return self.stations()
|
||||
|
||||
async def fetch_history(
|
||||
self, client: httpx.AsyncClient, station_id: str
|
||||
) -> list[Song]:
|
||||
current: Song | None = None
|
||||
if self.has_status:
|
||||
try:
|
||||
payload = await self._status(client, attempts=1)
|
||||
except RuntimeError:
|
||||
payload = None
|
||||
if payload is not None:
|
||||
current = self._now(payload)
|
||||
if current is None:
|
||||
current = self._scheduled_song()
|
||||
if current is None:
|
||||
return self.load_cached_history(station_id)
|
||||
|
||||
history = self.load_cached_history(station_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
if history and history[0].key() == current.key():
|
||||
# Same show or track: keep the time we first heard it.
|
||||
current.played_at = history[0].played_at
|
||||
history = [current, *history[1:]]
|
||||
else:
|
||||
current.played_at = current.played_at or now
|
||||
history = [current, *[s for s in history if s.key() != current.key()]]
|
||||
history = history[:HISTORY_LIMIT]
|
||||
|
||||
config.write_json(
|
||||
self._songs_cache(station_id),
|
||||
{"fetched": time.time(), "songs": self._songs_to_cache(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(),
|
||||
art_url=str(entry.get("art") or "").strip(),
|
||||
played_at=parse_date(entry.get("played_at")),
|
||||
)
|
||||
if song.title or song.artist:
|
||||
songs.append(song)
|
||||
return songs
|
||||
|
||||
def fallback_urls(self, station: Station) -> list[str]:
|
||||
return [url for url, _format, _quality, _bitrate in self.streams]
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Cashmere Radio: Berlin's experimental station, on an Airtime relay."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import CommunityProvider
|
||||
|
||||
|
||||
class CashmereProvider(CommunityProvider):
|
||||
id = "cashmere"
|
||||
name = "Cashmere Radio"
|
||||
homepage = "cashmereradio.com"
|
||||
brand = "CASHMERE"
|
||||
tagline = "experimental radio · berlin"
|
||||
station_source = "cashmereradio.com"
|
||||
history_source = ""
|
||||
|
||||
station_title = "Cashmere Radio"
|
||||
station_description = "experimental radio from Berlin"
|
||||
station_genre = "experimental|eclectic"
|
||||
streams = (
|
||||
("https://cashmereradio.out.airtime.pro/cashmereradio_a", "ogg", "high", "192"),
|
||||
("https://cashmereradio.out.airtime.pro/cashmereradio_b", "mp3", "high", "192"),
|
||||
)
|
||||
|
||||
wordmark = "CASHMERE"
|
||||
hero_lines = ("EXPERIMENTAL RADIO", "BERLIN")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Datafruits: the netlabel's internet radio, relayed by Streampusher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import CommunityProvider
|
||||
|
||||
|
||||
class DatafruitsProvider(CommunityProvider):
|
||||
id = "datafruits"
|
||||
name = "Datafruits"
|
||||
homepage = "datafruits.fm"
|
||||
brand = "DATAFRUITS"
|
||||
tagline = "netlabel radio · experimental"
|
||||
station_source = "streampusher relay + icecast status"
|
||||
history_source = "icecast title (rolling)"
|
||||
|
||||
station_title = "Datafruits"
|
||||
station_description = "internet radio from the Datafruits netlabel"
|
||||
station_genre = "electronic|experimental"
|
||||
streams = (("https://streampusher-relay.club/datafruits.mp3", "mp3", "high", "128"),)
|
||||
status_url = "https://streampusher-relay.club/status-json.xsl"
|
||||
|
||||
wordmark = "DATAFRUITS"
|
||||
hero_lines = ("NETLABEL RADIO", "EXPERIMENTAL ELECTRONIC")
|
||||
@@ -0,0 +1,108 @@
|
||||
"""dublab: Los Angeles community radio, with a public weekly schedule."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from ... import config
|
||||
from ...models import Broadcast, parse_date
|
||||
from ..base import fetch_json
|
||||
from .base import CommunityProvider
|
||||
|
||||
SCHEDULE_URL = "https://dublab.com/.netlify/functions/schedule"
|
||||
|
||||
|
||||
def parse_schedule(payload: Any) -> list[Broadcast]:
|
||||
"""The site's own calendar: ``[{startTime, endTime, summary, ...}]``."""
|
||||
if not isinstance(payload, list):
|
||||
return []
|
||||
broadcasts: list[Broadcast] = []
|
||||
for entry in payload:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
name = str(entry.get("summary") or entry.get("title") or "").strip()
|
||||
start = parse_date(entry.get("startTime") or entry.get("start"))
|
||||
end = parse_date(entry.get("endTime") or entry.get("end"))
|
||||
if not name or start is None:
|
||||
continue
|
||||
broadcasts.append(Broadcast(name=name, start=start, end=end))
|
||||
broadcasts.sort(key=lambda item: item.start or datetime.now(timezone.utc))
|
||||
return broadcasts
|
||||
|
||||
|
||||
class DublabProvider(CommunityProvider):
|
||||
id = "dublab"
|
||||
name = "dublab"
|
||||
homepage = "dublab.com"
|
||||
brand = "DUBLAB"
|
||||
tagline = "community radio · los angeles · since 1999"
|
||||
station_source = "dublab.com schedule"
|
||||
history_source = "dublab.com schedule (rolling)"
|
||||
|
||||
station_title = "dublab"
|
||||
station_description = "non-profit community radio from Los Angeles, since 1999"
|
||||
station_genre = "eclectic|experimental"
|
||||
streams = (("https://dublab.out.airtime.pro/dublab_a", "mp3", "high", "192"),)
|
||||
|
||||
# The schedule is the only metadata dublab publishes, so it drives both the
|
||||
# left pane and "what is on".
|
||||
has_schedule = True
|
||||
|
||||
wordmark = "DUBLAB"
|
||||
hero_lines = ("LOS ANGELES", "SINCE 1999")
|
||||
|
||||
async def fetch_schedule(self, client: httpx.AsyncClient) -> list[Broadcast]:
|
||||
try:
|
||||
payload = await fetch_json(client, SCHEDULE_URL, attempts=1)
|
||||
except RuntimeError:
|
||||
return self.load_cached_schedule()
|
||||
entries = self._mark_live(parse_schedule(payload))
|
||||
if not entries:
|
||||
return self.load_cached_schedule()
|
||||
config.write_json(
|
||||
config.schedule_cache_path(self.id),
|
||||
{
|
||||
"fetched": time.time(),
|
||||
"schedule": [
|
||||
{
|
||||
"name": item.name,
|
||||
"start": item.start.isoformat() if item.start else "",
|
||||
"end": item.end.isoformat() if item.end else "",
|
||||
}
|
||||
for item in entries
|
||||
],
|
||||
},
|
||||
)
|
||||
return entries
|
||||
|
||||
async def fetch_history(self, client: httpx.AsyncClient, station_id: str):
|
||||
# The schedule is the only "what is on" dublab publishes, so make sure
|
||||
# we have one before the base class looks for the running show.
|
||||
if not self.load_cached_schedule():
|
||||
with contextlib.suppress(Exception):
|
||||
await self.fetch_schedule(client)
|
||||
return await super().fetch_history(client, station_id)
|
||||
|
||||
def load_cached_schedule(self) -> list[Broadcast]:
|
||||
raw = config.read_json(config.schedule_cache_path(self.id))
|
||||
entries = raw.get("schedule") if isinstance(raw, dict) else None
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
broadcasts: list[Broadcast] = []
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
start = parse_date(entry.get("start"))
|
||||
name = str(entry.get("name") or "").strip()
|
||||
if not name or start is None:
|
||||
continue
|
||||
broadcasts.append(
|
||||
Broadcast(name=name, start=start, end=parse_date(entry.get("end")))
|
||||
)
|
||||
broadcasts.sort(key=lambda item: item.start or datetime.now(timezone.utc))
|
||||
return broadcasts
|
||||
@@ -0,0 +1,31 @@
|
||||
"""The Lot Radio: live from a shipping container in Brooklyn (HLS)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import CommunityProvider
|
||||
|
||||
|
||||
class LotProvider(CommunityProvider):
|
||||
id = "lot"
|
||||
name = "The Lot Radio"
|
||||
homepage = "thelotradio.com"
|
||||
brand = "THELOT"
|
||||
tagline = "live from the lot · brooklyn"
|
||||
station_source = "thelotradio.com (HLS)"
|
||||
history_source = ""
|
||||
|
||||
station_title = "The Lot Radio"
|
||||
station_description = "live radio from a shipping container in Brooklyn"
|
||||
station_genre = "eclectic|house"
|
||||
# The station streams HLS; mpv plays the playlist directly.
|
||||
streams = (
|
||||
(
|
||||
"https://livepeercdn.studio/hls/85c28sa2o8wppm58/index.m3u8",
|
||||
"aac",
|
||||
"high",
|
||||
"",
|
||||
),
|
||||
)
|
||||
|
||||
wordmark = "THELOT"
|
||||
hero_lines = ("BROOKLYN", "LIVE FROM THE LOT")
|
||||
@@ -0,0 +1,23 @@
|
||||
"""LYL Radio: independent webradio from Lyon and Paris."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import CommunityProvider
|
||||
|
||||
|
||||
class LylProvider(CommunityProvider):
|
||||
id = "lyl"
|
||||
name = "LYL Radio"
|
||||
homepage = "lyl.live"
|
||||
brand = "LYL"
|
||||
tagline = "we're the rest · lyon · paris"
|
||||
station_source = "icecast.lyl.live"
|
||||
history_source = ""
|
||||
|
||||
station_title = "LYL Radio"
|
||||
station_description = "independent webradio from Lyon and Paris"
|
||||
station_genre = "eclectic|experimental"
|
||||
streams = (("https://icecast.lyl.live/live", "mp3", "high", "192"),)
|
||||
|
||||
wordmark = "LYL"
|
||||
hero_lines = ("WE'RE THE REST", "LYON · PARIS")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Refuge Worldwide: Berlin community radio on a radio.co relay."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import CommunityProvider
|
||||
|
||||
|
||||
class RefugeProvider(CommunityProvider):
|
||||
id = "refuge"
|
||||
name = "Refuge Worldwide"
|
||||
homepage = "refugeworldwide.com"
|
||||
brand = "REFUGE"
|
||||
tagline = "community radio · berlin"
|
||||
station_source = "radio.co status"
|
||||
history_source = "radio.co now playing (rolling)"
|
||||
|
||||
station_title = "Refuge Worldwide"
|
||||
station_description = "community radio from Berlin"
|
||||
station_genre = "eclectic|electronic"
|
||||
streams = (("https://streaming.radio.co/s3699c5e49/listen", "mp3", "high", "192"),)
|
||||
radio_co_id = "s3699c5e49"
|
||||
|
||||
wordmark = "REFUGE"
|
||||
hero_lines = ("COMMUNITY RADIO", "BERLIN")
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Rinse FM: London's station family, a proxy mount each."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ...models import Station
|
||||
from .base import CommunityProvider
|
||||
|
||||
#: (station id, title, genre, mount, description)
|
||||
_STATIONS: tuple[tuple[str, str, str, str, str], ...] = (
|
||||
(
|
||||
"rinse_uk",
|
||||
"Rinse UK",
|
||||
"electronic|house",
|
||||
"rinse_uk",
|
||||
"London's underground dance station, on air since 1994",
|
||||
),
|
||||
(
|
||||
"kool",
|
||||
"Kool London",
|
||||
"jungle|drum & bass",
|
||||
"kool",
|
||||
"Kool London: jungle, drum & bass and roots",
|
||||
),
|
||||
(
|
||||
"swu",
|
||||
"SWU",
|
||||
"soul|eclectic",
|
||||
"swu",
|
||||
"SWU: soul, funk and everything south west",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class RinseProvider(CommunityProvider):
|
||||
id = "rinse"
|
||||
name = "Rinse FM"
|
||||
homepage = "rinse.fm"
|
||||
brand = "RINSE"
|
||||
tagline = "london · since 1994"
|
||||
station_source = "rinse.fm streams"
|
||||
history_source = ""
|
||||
|
||||
station_genre = "electronic"
|
||||
|
||||
wordmark = "RINSE"
|
||||
hero_lines = ("LONDON", "SINCE 1994")
|
||||
|
||||
def stations(self) -> list[Station]:
|
||||
return [
|
||||
Station(
|
||||
id=station_id,
|
||||
title=title,
|
||||
description=description,
|
||||
genre=genre,
|
||||
playlists=[
|
||||
{
|
||||
"url": f"https://admin.stream.rinse.fm/proxy/{mount}/stream",
|
||||
"format": "aacp",
|
||||
"quality": "high",
|
||||
"bitrate": "128",
|
||||
}
|
||||
],
|
||||
provider=self.id,
|
||||
)
|
||||
for station_id, title, genre, mount, description in _STATIONS
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Radio Browser: a directory of the community's stations rather than a station.
|
||||
|
||||
Three hundred of the most-clicked working relays, keyless and public-domain.
|
||||
There is no now-playing API, so this catalogue offers breadth, not history: pick
|
||||
a station and the stream speaks for itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from ... import config
|
||||
from ...models import Song, Station
|
||||
from ..base import RadioProvider
|
||||
from . import api, fallback
|
||||
from . import hero as hero_art
|
||||
|
||||
__all__ = ["RadioBrowserProvider"]
|
||||
|
||||
|
||||
class RadioBrowserProvider(RadioProvider):
|
||||
id = "radiobrowser"
|
||||
name = "Radio Browser"
|
||||
homepage = "radio-browser.info"
|
||||
brand = "RADIO BROWSER"
|
||||
tagline = "community directory · 50,000 stations"
|
||||
station_source = "radio-browser.info (top 300)"
|
||||
history_source = ""
|
||||
|
||||
hero = hero_art.HERO
|
||||
hero_lines = ("RADIO BROWSER", "COMMUNITY DIRECTORY", "KEYLESS · OPEN DATA")
|
||||
|
||||
# A directory has no show schedule and no Bandcamp worth scraping.
|
||||
bandcamp = False
|
||||
|
||||
@property
|
||||
def user_agent(self) -> str:
|
||||
return f"{config.USER_AGENT}; +https://www.radio-browser.info"
|
||||
|
||||
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
|
||||
return await api.fetch_stations(client)
|
||||
|
||||
def load_cached_stations(self) -> list[Station]:
|
||||
return api.load_cached_stations()
|
||||
|
||||
def fallback_stations(self) -> list[Station]:
|
||||
return fallback.fallback_stations()
|
||||
|
||||
async def fetch_history(
|
||||
self, client: httpx.AsyncClient, station_id: str
|
||||
) -> list[Song]:
|
||||
# Radio Browser publishes no now-playing data at all.
|
||||
return []
|
||||
|
||||
def load_cached_history(self, station_id: str) -> list[Song]:
|
||||
return []
|
||||
|
||||
def fallback_urls(self, station: Station) -> list[str]:
|
||||
return [entry["url"] for entry in station.sorted_playlists()]
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Radio Browser: the community's own station directory.
|
||||
|
||||
``https://de1.api.radio-browser.info/json/stations/search`` is keyless, CORS-open
|
||||
and public-domain data. It knows nothing about what a station is playing —
|
||||
there is no per-station history — so this catalogue is a *directory*: three
|
||||
hundred of the most-clicked working stations, ready to tune into. Whatever the
|
||||
relay puts in its stream is what you hear; the app has no metadata to show.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from ... import config
|
||||
from ...models import Station, to_int
|
||||
from ..base import fetch_json
|
||||
|
||||
API_URL = "https://de1.api.radio-browser.info/json/stations/search"
|
||||
|
||||
#: Three hundred is a comfortable directory: enough to browse, quick to fetch.
|
||||
STATION_LIMIT = 300
|
||||
|
||||
_PARAMS = {
|
||||
"hidebroken": "true",
|
||||
"order": "clickcount",
|
||||
"reverse": "true",
|
||||
"limit": str(STATION_LIMIT),
|
||||
}
|
||||
|
||||
_CODECS = {"mp3": "mp3", "aac": "aac", "aac+": "aacp", "ogg": "ogg", "opus": "opus"}
|
||||
|
||||
|
||||
def _quality(bitrate: int) -> str:
|
||||
if bitrate >= 192:
|
||||
return "highest"
|
||||
if bitrate >= 96:
|
||||
return "high"
|
||||
return "low" if bitrate else ""
|
||||
|
||||
|
||||
def station_from_entry(entry: dict[str, Any]) -> Station | None:
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
name = str(entry.get("name") or "").strip()
|
||||
url = str(entry.get("url_resolved") or entry.get("url") or "").strip()
|
||||
station_id = str(entry.get("stationuuid") or "").strip()
|
||||
if not name or not station_id or not url.startswith(("http://", "https://")):
|
||||
return None
|
||||
|
||||
bitrate = to_int(entry.get("bitrate"))
|
||||
codec = str(entry.get("codec") or "").strip().lower()
|
||||
tags = [
|
||||
tag.strip()
|
||||
for tag in str(entry.get("tags") or "").split(",")
|
||||
if tag.strip()
|
||||
][:3]
|
||||
country = str(entry.get("country") or "").strip()
|
||||
language = str(entry.get("language") or "").strip()
|
||||
description = " · ".join(part for part in (country, language) if part)
|
||||
|
||||
favicon = str(entry.get("favicon") or "").strip()
|
||||
|
||||
return Station(
|
||||
id=station_id,
|
||||
title=name,
|
||||
description=description or "internet radio",
|
||||
genre="|".join(tags),
|
||||
image=favicon if favicon.startswith(("http://", "https://")) else "",
|
||||
playlists=[
|
||||
{
|
||||
"url": url,
|
||||
"format": _CODECS.get(codec, codec),
|
||||
"quality": _quality(bitrate),
|
||||
"bitrate": str(bitrate) if bitrate else "",
|
||||
}
|
||||
],
|
||||
provider="radiobrowser",
|
||||
)
|
||||
|
||||
|
||||
def parse_stations(payload: Any) -> list[Station]:
|
||||
if not isinstance(payload, list):
|
||||
return []
|
||||
stations: list[Station] = []
|
||||
seen: set[str] = set()
|
||||
for entry in payload:
|
||||
station = station_from_entry(entry)
|
||||
if station is None or station.id in seen:
|
||||
continue
|
||||
seen.add(station.id)
|
||||
stations.append(station)
|
||||
stations.sort(key=lambda station: station.title.casefold())
|
||||
return stations
|
||||
|
||||
|
||||
async def fetch_stations(client: httpx.AsyncClient) -> list[Station]:
|
||||
url = f"{API_URL}?{urlencode(_PARAMS)}"
|
||||
try:
|
||||
payload = await fetch_json(client, url)
|
||||
except RuntimeError as exc:
|
||||
raise RuntimeError(f"could not fetch the Radio Browser directory: {exc}") from None
|
||||
stations = parse_stations(payload)
|
||||
if not stations:
|
||||
raise RuntimeError("Radio Browser returned no stations")
|
||||
config.write_json(config.channels_cache_path("radiobrowser"), payload)
|
||||
return stations
|
||||
|
||||
|
||||
def load_cached_stations() -> list[Station]:
|
||||
return parse_stations(config.read_json(config.channels_cache_path("radiobrowser")))
|
||||
@@ -0,0 +1,44 @@
|
||||
"""A handful of directory entries for a cold offline start.
|
||||
|
||||
Radio Browser itself is the directory, but an offline start has no cache — so
|
||||
this is ten stations whose relays have been up for years, one per taste.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ...models import Station
|
||||
|
||||
#: (id, title, country, genre tags, url, format, bitrate)
|
||||
_STATIONS: tuple[tuple[str, str, str, str, str, str, str], ...] = (
|
||||
("fallback-groovesalad", "SomaFM Groove Salad", "United States", "ambient|chill", "https://ice1.somafm.com/groovesalad-128-mp3", "mp3", "128"),
|
||||
("fallback-radioparadise", "Radio Paradise", "United States", "eclectic|rock", "https://stream.radioparadise.com/flac", "flac", "1411"),
|
||||
("fallback-fip", "FIP", "France", "eclectic|jazz", "https://icecast.radiofrance.fr/fip-midfi.mp3", "mp3", "128"),
|
||||
("fallback-kexp", "KEXP", "United States", "alternative|indie", "https://kexp.streamguys1.com/kexp160.aac", "aac", "160"),
|
||||
("fallback-nts1", "NTS Radio 1", "United Kingdom", "eclectic|electronic", "https://stream-relay-geo.ntslive.net/stream", "mp3", "128"),
|
||||
("fallback-rinse", "Rinse UK", "United Kingdom", "house|electronic", "https://admin.stream.rinse.fm/proxy/rinse_uk/stream", "aacp", "128"),
|
||||
("fallback-dublab", "dublab", "United States", "eclectic|experimental", "https://dublab.out.airtime.pro/dublab_a", "mp3", "192"),
|
||||
("fallback-cashmere", "Cashmere Radio", "Germany", "experimental", "https://cashmereradio.out.airtime.pro/cashmereradio_a", "ogg", "192"),
|
||||
("fallback-datafruits", "Datafruits", "United States", "electronic|experimental", "https://streampusher-relay.club/datafruits.mp3", "mp3", "128"),
|
||||
("fallback-anonradio", "aNONradio", "United States", "eclectic|talk", "https://anonradio.net:8443/anonradio", "mp3", "192"),
|
||||
)
|
||||
|
||||
|
||||
def fallback_stations() -> list[Station]:
|
||||
return [
|
||||
Station(
|
||||
id=station_id,
|
||||
title=title,
|
||||
description=country,
|
||||
genre=genre,
|
||||
playlists=[
|
||||
{
|
||||
"url": url,
|
||||
"format": fmt,
|
||||
"quality": "high",
|
||||
"bitrate": bitrate,
|
||||
}
|
||||
],
|
||||
provider="radiobrowser",
|
||||
)
|
||||
for station_id, title, country, genre, url, fmt, bitrate in _STATIONS
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Radio Browser's header hero: the directory's own name."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..wordmark import render
|
||||
|
||||
HERO: tuple[str, ...] = render("RADIOBROWSER")
|
||||
@@ -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")
|
||||
@@ -18,18 +18,27 @@ from collections.abc import Sequence
|
||||
GLYPHS: dict[str, tuple[str, ...]] = {
|
||||
"A": (".##.", "#..#", "####", "#..#", "#..#", "#..#"),
|
||||
"B": ("###.", "#..#", "###.", "#..#", "#..#", "###."),
|
||||
"C": (".##.", "#..#", "#...", "#...", "#..#", ".##."),
|
||||
"D": ("###.", "#..#", "#..#", "#..#", "#..#", "###."),
|
||||
"E": ("####", "#...", "###.", "#...", "#...", "####"),
|
||||
"F": ("####", "#...", "###.", "#...", "#...", "#..."),
|
||||
"G": (".##.", "#..#", "#...", "#.##", "#..#", ".##."),
|
||||
"H": ("#..#", "#..#", "####", "#..#", "#..#", "#..#"),
|
||||
"I": ("###", ".#.", ".#.", ".#.", ".#.", "###"),
|
||||
"K": ("#..#", "#.#.", "##..", "##..", "#.#.", "#..#"),
|
||||
"L": ("#...", "#...", "#...", "#...", "#...", "####"),
|
||||
# 5 wide on purpose: the M is the odd one out in the original wordmark.
|
||||
"M": ("#...#", "##.##", "#.#.#", "#.#.#", "#...#", "#...#"),
|
||||
"N": ("#..#", "##.#", "##.#", "#.##", "#..#", "#..#"),
|
||||
"O": (".##.", "#..#", "#..#", "#..#", "#..#", ".##."),
|
||||
"P": ("###.", "#..#", "###.", "#...", "#...", "#..."),
|
||||
"R": ("###.", "#..#", "###.", "#.#.", "#..#", "#..#"),
|
||||
"S": (".###", "#...", "###.", "...#", "...#", "###."),
|
||||
"T": ("###", ".#.", ".#.", ".#.", ".#.", ".#."),
|
||||
"U": ("#..#", "#..#", "#..#", "#..#", "#..#", ".##."),
|
||||
"V": ("#..#", "#..#", "#..#", "#..#", ".##.", ".##."),
|
||||
"W": ("#...#", "#...#", "#.#.#", "#.#.#", "##.##", "#...#"),
|
||||
"Y": ("#..#", "#..#", ".##.", "..#.", "..#.", "..#."),
|
||||
}
|
||||
|
||||
ROWS = 3
|
||||
|
||||
Reference in new issue
Block a user