Files
beefy-player/beefy_player/providers/tilderadio/api.py
T

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