402 lines
13 KiB
Python
402 lines
13 KiB
Python
"""tilderadio's public endpoints, and nothing but.
|
|
|
|
* ``https://tilderadio.org/api/now/`` -> the station, listener count, the live
|
|
DJ, the current track and the last handful of tracks
|
|
* ``https://azuracast.tilderadio.org/api/nowplaying/tilderadio`` -> the mounts
|
|
(mp3/ogg, bitrates) behind the stream, so we can offer the best one first
|
|
|
|
tilderadio is a single-station catalogue: one community stream for the whole
|
|
tildeverse, run on an AzuraCast instance. That makes the "station list" a list
|
|
of one, and it means ``/now/`` is both the catalogue *and* the history feed —
|
|
the current track arrives as ``now_playing``, the previous ones as
|
|
``history[]``. Everything here is forgiving: the station is better served by a
|
|
slightly degraded panel than by a crash.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import time
|
|
from collections.abc import Iterable
|
|
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 fetch_json, quote_station_id
|
|
|
|
PROVIDER_ID = "tilderadio"
|
|
API_ROOT = "https://tilderadio.org/api"
|
|
NOW_URL = f"{API_ROOT}/now/"
|
|
SCHEDULE_URL = f"{API_ROOT}/schedule/"
|
|
AZURACAST_URL = "https://azuracast.tilderadio.org/api/nowplaying/{station}"
|
|
|
|
DEFAULT_STATION_ID = "tilderadio"
|
|
STATION_IMAGE = "https://tilderadio.org/logos/tilderadio.png"
|
|
DESCRIPTION = "community radio for the tildeverse"
|
|
GENRE = "community|eclectic"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# payload -> Station / Song
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _artist_title(entry: dict[str, Any]) -> tuple[str, str]:
|
|
"""AzuraCast sends ``artist``/``title`` *or* one ``"Artist - Title"`` blob."""
|
|
artist = str(entry.get("artist") or "").strip()
|
|
title = str(entry.get("title") or "").strip()
|
|
if not artist:
|
|
text = str(entry.get("text") or "").strip()
|
|
split = parse_last_playing(text) if text else None
|
|
if split is not None:
|
|
artist, title = split.artist or artist, split.title or title
|
|
return artist, title
|
|
|
|
|
|
def song_from_track(entry: Any) -> Song | None:
|
|
if not isinstance(entry, dict):
|
|
return None
|
|
artist, title = _artist_title(entry)
|
|
if not (artist or title):
|
|
return None
|
|
return Song(
|
|
title=title,
|
|
artist=artist,
|
|
album=str(entry.get("album") or "").strip(),
|
|
art_url=str(entry.get("art") or entry.get("albumArt") or "").strip(),
|
|
played_at=parse_date(entry.get("played_at") or entry.get("date")),
|
|
)
|
|
|
|
|
|
def parse_tracks(entries: Any) -> list[Song]:
|
|
"""Newest first, and the same track only once even if the feed repeats it."""
|
|
if not isinstance(entries, list):
|
|
return []
|
|
songs: list[Song] = []
|
|
seen: set[tuple[str, Any]] = set()
|
|
for entry in entries:
|
|
song = song_from_track(entry)
|
|
if song is None:
|
|
continue
|
|
marker = (song.key(), song.played_at)
|
|
if marker in seen:
|
|
continue
|
|
seen.add(marker)
|
|
songs.append(song)
|
|
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_from_now(payload: Any) -> list[Song]:
|
|
"""``now_playing`` followed by ``history[]``, as one newest-first list."""
|
|
if not isinstance(payload, dict):
|
|
return []
|
|
entries: list[Any] = [payload.get("now_playing")]
|
|
history = payload.get("history")
|
|
if isinstance(history, list):
|
|
entries.extend(history)
|
|
return parse_tracks(entries)
|
|
|
|
|
|
def _quality(bitrate: int) -> str:
|
|
if bitrate >= 256:
|
|
return "highest"
|
|
if bitrate >= 128:
|
|
return "high"
|
|
return "low" if bitrate else ""
|
|
|
|
|
|
def playlists_from_mounts(payload: Any) -> list[dict[str, str]]:
|
|
"""AzuraCast's mount list -> the playlist dicts :class:`Station` speaks."""
|
|
if not isinstance(payload, dict):
|
|
return []
|
|
info = payload.get("station")
|
|
mounts = info.get("mounts") if isinstance(info, dict) else None
|
|
if not isinstance(mounts, list):
|
|
return []
|
|
playlists: list[dict[str, str]] = []
|
|
seen: set[str] = set()
|
|
for mount in mounts:
|
|
if not isinstance(mount, dict):
|
|
continue
|
|
url = mount.get("url")
|
|
if not isinstance(url, str) or not url.startswith(("http://", "https://")):
|
|
continue
|
|
if url in seen:
|
|
continue
|
|
seen.add(url)
|
|
bitrate = to_int(mount.get("bitrate"))
|
|
playlists.append(
|
|
{
|
|
"url": url,
|
|
"format": str(mount.get("format") or "").strip().lower(),
|
|
"quality": _quality(bitrate),
|
|
"bitrate": str(bitrate) if bitrate else "",
|
|
}
|
|
)
|
|
return playlists
|
|
|
|
|
|
def _playlists_from_listen_url(info: dict[str, Any]) -> list[dict[str, str]]:
|
|
url = info.get("listen_url")
|
|
if not isinstance(url, str) or not url.startswith(("http://", "https://")):
|
|
return []
|
|
return [{"url": url, "format": "ogg", "quality": "high", "bitrate": ""}]
|
|
|
|
|
|
def _is_live(payload: dict[str, Any]) -> bool:
|
|
live = payload.get("live")
|
|
if isinstance(live, dict):
|
|
return bool(live.get("is_live"))
|
|
return bool(payload.get("is_live"))
|
|
|
|
|
|
def _live_dj(payload: dict[str, Any]) -> str:
|
|
"""Only name a DJ when the station says someone is actually on air."""
|
|
if not _is_live(payload):
|
|
return ""
|
|
live = payload.get("live")
|
|
if isinstance(live, dict):
|
|
return str(live.get("name") or live.get("dj") or "").strip()
|
|
return str(payload.get("dj") or "").strip()
|
|
|
|
|
|
def station_from_now(
|
|
payload: Any, playlists: Iterable[Any] = ()
|
|
) -> Station | None:
|
|
"""The whole catalogue, from one ``/api/now/`` response."""
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
info = payload.get("station")
|
|
if not isinstance(info, dict):
|
|
return None
|
|
station_id = (
|
|
str(info.get("shortcode") or info.get("name") or DEFAULT_STATION_ID).strip()
|
|
or DEFAULT_STATION_ID
|
|
)
|
|
|
|
known = [dict(entry) for entry in playlists if isinstance(entry, dict)]
|
|
station = Station(
|
|
id=station_id,
|
|
title=str(info.get("name") or station_id).strip() or DEFAULT_STATION_ID,
|
|
description=str(info.get("description") or "").strip() or DESCRIPTION,
|
|
genre=GENRE,
|
|
dj=_live_dj(payload),
|
|
presenter="" if _is_live(payload) else "auto dj · library rotation",
|
|
image=STATION_IMAGE,
|
|
listeners=to_int(payload.get("listeners")),
|
|
playlists=known or _playlists_from_listen_url(info),
|
|
provider=PROVIDER_ID,
|
|
)
|
|
|
|
current = payload.get("now_playing")
|
|
if isinstance(current, dict):
|
|
song = song_from_track(current)
|
|
if song is not None:
|
|
station.now_playing = song
|
|
station.last_playing = str(current.get("text") or "").strip() or (
|
|
f"{song.artist} - {song.title}" if song.artist else song.title
|
|
)
|
|
return station
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# schedule
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def parse_schedule(payload: Any) -> list[Broadcast]:
|
|
"""``/api/schedule/`` -> broadcasts, soonest first."""
|
|
entries = payload.get("schedule") if isinstance(payload, dict) else None
|
|
if not isinstance(entries, list):
|
|
return []
|
|
broadcasts: list[Broadcast] = []
|
|
for entry in entries:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
name = str(entry.get("name") or entry.get("slug") or "").strip()
|
|
start = parse_date(entry.get("start_ts") or entry.get("start"))
|
|
end = parse_date(entry.get("end_ts") or entry.get("end"))
|
|
if not name or start is None:
|
|
continue
|
|
broadcasts.append(Broadcast(name=name, dj=name, start=start, end=end))
|
|
broadcasts.sort(key=lambda item: item.start or datetime.now(timezone.utc))
|
|
return broadcasts
|
|
|
|
|
|
def live_broadcast(payload: Any) -> Broadcast | None:
|
|
"""The show that is on air right now, if the feed says there is one."""
|
|
if not isinstance(payload, dict) or not payload.get("is_live"):
|
|
return None
|
|
name = _live_dj(payload) or "live broadcast"
|
|
return Broadcast(
|
|
name=name,
|
|
dj=name,
|
|
start=parse_date(payload.get("broadcast_start")),
|
|
end=parse_date(payload.get("broadcast_end")),
|
|
live=True,
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# caching
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def station_cache_path() -> Path:
|
|
return config.channels_cache_path(PROVIDER_ID)
|
|
|
|
|
|
def _songs_cache_path(station_id: str) -> Path:
|
|
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", station_id or DEFAULT_STATION_ID)
|
|
return config.SONGS_CACHE_DIR / f"{PROVIDER_ID}-{safe}.json"
|
|
|
|
|
|
def _songs_to_cache(songs: Iterable[Song]) -> list[dict[str, Any]]:
|
|
return [
|
|
{
|
|
"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 songs
|
|
]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# fetching
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
async def _fetch_mounts(
|
|
client: httpx.AsyncClient, station_id: str
|
|
) -> list[dict[str, str]]:
|
|
url = AZURACAST_URL.format(station=quote_station_id(station_id))
|
|
try:
|
|
payload = await fetch_json(client, url, attempts=1)
|
|
except RuntimeError:
|
|
return []
|
|
return playlists_from_mounts(payload)
|
|
|
|
|
|
async def fetch_station(client: httpx.AsyncClient) -> Station:
|
|
try:
|
|
payload = await fetch_json(client, NOW_URL)
|
|
except RuntimeError as exc:
|
|
raise RuntimeError(f"could not fetch tilderadio now-playing: {exc}") from None
|
|
|
|
station = station_from_now(payload)
|
|
if station is None:
|
|
raise RuntimeError("tilderadio returned no station data")
|
|
|
|
# Bitrates and the mp3 mount only come from AzuraCast; losing them is not
|
|
# a reason to lose the station.
|
|
playlists = await _fetch_mounts(client, station.id)
|
|
if playlists:
|
|
station.playlists = playlists
|
|
|
|
config.write_json(
|
|
station_cache_path(),
|
|
{"fetched": time.time(), "now": payload, "mounts": station.playlists},
|
|
)
|
|
return station
|
|
|
|
|
|
def load_cached_station() -> list[Station]:
|
|
raw = config.read_json(station_cache_path())
|
|
if not isinstance(raw, dict):
|
|
return []
|
|
mounts = raw.get("mounts")
|
|
station = station_from_now(
|
|
raw.get("now"), mounts if isinstance(mounts, list) else []
|
|
)
|
|
return [station] if station is not None else []
|
|
|
|
|
|
async def fetch_songs(client: httpx.AsyncClient, station_id: str) -> list[Song]:
|
|
try:
|
|
payload = await fetch_json(client, NOW_URL, attempts=1)
|
|
except RuntimeError:
|
|
return load_cached_songs(station_id)
|
|
|
|
songs = songs_from_now(payload)
|
|
if not songs:
|
|
return load_cached_songs(station_id)
|
|
config.write_json(
|
|
_songs_cache_path(station_id),
|
|
{"fetched": time.time(), "songs": _songs_to_cache(songs)},
|
|
)
|
|
return songs
|
|
|
|
|
|
def load_cached_songs(station_id: str) -> list[Song]:
|
|
raw = config.read_json(_songs_cache_path(station_id))
|
|
if isinstance(raw, dict):
|
|
return parse_tracks(raw.get("songs"))
|
|
return []
|
|
|
|
|
|
def _schedule_cache_path() -> Path:
|
|
return config.schedule_cache_path(PROVIDER_ID)
|
|
|
|
|
|
def load_cached_schedule() -> list[Broadcast]:
|
|
raw = config.read_json(_schedule_cache_path())
|
|
entries = raw.get("schedule") if isinstance(raw, dict) else None
|
|
if not isinstance(entries, list):
|
|
return []
|
|
return parse_schedule({"schedule": entries})
|
|
|
|
|
|
async def fetch_schedule(client: httpx.AsyncClient) -> list[Broadcast]:
|
|
"""What is on air now (if anything) followed by what is coming up."""
|
|
live: Broadcast | None = None
|
|
try:
|
|
now_payload = await fetch_json(client, NOW_URL, attempts=1)
|
|
except RuntimeError:
|
|
now_payload = None
|
|
live = live_broadcast(now_payload)
|
|
|
|
try:
|
|
payload = await fetch_json(client, SCHEDULE_URL, attempts=1)
|
|
except RuntimeError:
|
|
upcoming = load_cached_schedule()
|
|
else:
|
|
upcoming = parse_schedule(payload)
|
|
config.write_json(
|
|
_schedule_cache_path(),
|
|
{
|
|
"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 upcoming
|
|
],
|
|
},
|
|
)
|
|
|
|
now = datetime.now(timezone.utc)
|
|
upcoming = [
|
|
item
|
|
for item in upcoming
|
|
if not item.live and (item.end or item.start or now) >= now
|
|
]
|
|
return ([live] if live is not None else []) + upcoming
|