"""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