feat(tilderadio): add the tildeverse community stream as a catalogue
This commit is contained in:
1 parent
58e9202e25
commit
058d336a2c
8 files changed
+883
-4
No files matched your search
+37
-1
@@ -3,7 +3,9 @@
|
||||
Everything lives in the usual XDG places:
|
||||
|
||||
~/.config/beefy-player/config.json favourites, volume, station
|
||||
~/.cache/beefy-player/channels.json last good station list
|
||||
~/.cache/beefy-player/channels.json last good station list (SomaFM)
|
||||
~/.cache/beefy-player/channels-<id>.json ... for every other catalogue
|
||||
~/.cache/beefy-player/schedule-<id>.json last good broadcast schedule
|
||||
~/.cache/beefy-player/playlists.json resolved .pls -> stream URLs
|
||||
~/.cache/beefy-player/songs/<id>.json last good song history
|
||||
~/.cache/beefy-player/artwork/<sha1> cached album/channel artwork
|
||||
@@ -17,6 +19,7 @@ from __future__ import annotations
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -70,6 +73,24 @@ LEGACY_CONFIG_PATH = _xdg("XDG_CONFIG_HOME", ".config") / LEGACY_APP_NAME / "con
|
||||
VISUALIZERS = ("both", "bars", "wave", "waterfall", "radial", "braille")
|
||||
|
||||
|
||||
def channels_cache_path(provider_id: str) -> Path:
|
||||
"""Where a catalogue caches its last good station list.
|
||||
|
||||
SomaFM keeps the historical ``channels.json``; every other provider gets
|
||||
its own file, so two catalogues can never overwrite each other's cache.
|
||||
"""
|
||||
slug = re.sub(r"[^a-z0-9_-]", "_", provider_id.strip().lower())
|
||||
if not slug or slug == "somafm":
|
||||
return CHANNELS_CACHE
|
||||
return CACHE_DIR / f"channels-{slug}.json"
|
||||
|
||||
|
||||
def schedule_cache_path(provider_id: str) -> Path:
|
||||
"""Where a catalogue caches its last good broadcast schedule."""
|
||||
slug = re.sub(r"[^a-z0-9_-]", "_", provider_id.strip().lower()) or "provider"
|
||||
return CACHE_DIR / f"schedule-{slug}.json"
|
||||
|
||||
|
||||
def ensure_dirs() -> None:
|
||||
for directory in (CONFIG_DIR, CACHE_DIR, SONGS_CACHE_DIR):
|
||||
with contextlib.suppress(OSError):
|
||||
@@ -115,6 +136,9 @@ def migrate_legacy_state() -> None:
|
||||
class Config:
|
||||
favorites: list[str] = field(default_factory=list)
|
||||
last_station: str = "groovesalad"
|
||||
#: Which station each catalogue was last tuned to, keyed by provider slug,
|
||||
#: so switching back lands where you left off.
|
||||
last_stations: dict[str, str] = field(default_factory=dict)
|
||||
volume: int = 70
|
||||
visualizer: str = "both"
|
||||
autoplay: bool = False
|
||||
@@ -141,6 +165,14 @@ class Config:
|
||||
if isinstance(last_station, str) and last_station:
|
||||
config.last_station = last_station
|
||||
|
||||
last_stations = raw.get("last_stations")
|
||||
if isinstance(last_stations, dict):
|
||||
config.last_stations = {
|
||||
str(key): str(value)
|
||||
for key, value in last_stations.items()
|
||||
if isinstance(value, str) and value
|
||||
}
|
||||
|
||||
volume = raw.get("volume")
|
||||
if isinstance(volume, (int, float)):
|
||||
config.volume = max(0, min(100, int(volume)))
|
||||
@@ -168,6 +200,10 @@ class Config:
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def last_station_for(self, provider_id: str) -> str:
|
||||
"""The station to re-tune for a catalogue (old configs have one only)."""
|
||||
return self.last_stations.get(provider_id) or self.last_station
|
||||
|
||||
def is_favorite(self, station_id: str) -> bool:
|
||||
return station_id in self.favorites
|
||||
|
||||
|
||||
+38
-1
@@ -40,6 +40,25 @@ class Song:
|
||||
return f"{self.artist}\x1f{self.title}\x1f{self.album}"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Broadcast:
|
||||
"""One entry in a catalogue's schedule: a show, a DJ slot, a stream."""
|
||||
|
||||
name: str
|
||||
dj: str = ""
|
||||
start: datetime | None = None
|
||||
end: datetime | None = None
|
||||
#: True for the broadcast that is on air right now.
|
||||
live: bool = False
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return self.name.strip() or self.dj.strip() or "untitled broadcast"
|
||||
|
||||
def key(self) -> str:
|
||||
return f"{self.name}\x1f{self.start.isoformat() if self.start else ''}"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Station:
|
||||
id: str
|
||||
@@ -47,6 +66,9 @@ class Station:
|
||||
description: str = ""
|
||||
genre: str = ""
|
||||
dj: str = ""
|
||||
#: Who is presenting right now when that is more than a DJ name —
|
||||
#: ``"auto dj · library rotation"`` for a station between live shows.
|
||||
presenter: str = ""
|
||||
image: str = ""
|
||||
listeners: int = 0
|
||||
last_playing: str = ""
|
||||
@@ -69,6 +91,15 @@ class Station:
|
||||
def genre_label(self) -> str:
|
||||
return self.genre.replace("|", " · ") or "eclectic"
|
||||
|
||||
@property
|
||||
def presenter_label(self) -> str:
|
||||
"""What the ON AIR metadata line says about who is on the desk."""
|
||||
if self.presenter.strip():
|
||||
return self.presenter.strip()
|
||||
if self.dj.strip():
|
||||
return f"dj {self.dj.strip()}"
|
||||
return ""
|
||||
|
||||
def sorted_playlists(self) -> list[dict[str, str]]:
|
||||
return sorted(
|
||||
self.playlists,
|
||||
@@ -87,7 +118,7 @@ class Station:
|
||||
fmt = _FORMAT_LABEL.get(
|
||||
first.get("format", ""), first.get("format") or "stream"
|
||||
)
|
||||
return f"{fmt} · {guess_bitrate(first.get('url', ''))}k"
|
||||
return f"{fmt} · {playlist_bitrate(first)}k"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -95,6 +126,12 @@ class Station:
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def playlist_bitrate(playlist: dict[str, str]) -> int:
|
||||
"""The bitrate a catalogue declared, else the best guess from the URL."""
|
||||
declared = to_int(playlist.get("bitrate"))
|
||||
return declared or guess_bitrate(playlist.get("url", ""))
|
||||
|
||||
|
||||
def guess_bitrate(url: str) -> int:
|
||||
match = re.search(
|
||||
r"(256|192|130|128|96|64|48|32)(?=\.pls|\.m3u|-mp3|-aac)", url
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""tilderadio: the tildeverse's community station.
|
||||
|
||||
One stream, one catalogue entry, a live DJ when a show is on and the shared
|
||||
library rotation the rest of the time. Everything tilderadio-specific — the
|
||||
``/api/now/`` schema, the ``now_playing`` + ``history[]`` pairing, the AzuraCast
|
||||
mount list and the bundled fallback — lives in this package; the app only sees
|
||||
:class:`TilderadioProvider`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from ... import config
|
||||
from ...models import Broadcast, Song, Station
|
||||
from ..base import RadioProvider
|
||||
from . import api, fallback
|
||||
from . import hero as hero_art
|
||||
|
||||
__all__ = ["TilderadioProvider"]
|
||||
|
||||
|
||||
class TilderadioProvider(RadioProvider):
|
||||
id = "tilderadio"
|
||||
name = "tilderadio"
|
||||
homepage = "tilderadio.org"
|
||||
brand = "TILDE RADIO"
|
||||
tagline = "community radio · tildeverse"
|
||||
station_source = "tilderadio.org/api/now/"
|
||||
history_source = "now_playing + history[]"
|
||||
|
||||
# The station's own radio icon, converted to half blocks by
|
||||
# ``tools/ansi_art.py``; the caption is the tildeverse's motto.
|
||||
hero = hero_art.HERO
|
||||
hero_lines = (
|
||||
"TILDERADIO",
|
||||
"STREAMED BY / FOR USERS OF THE",
|
||||
"TILDEVERSE",
|
||||
)
|
||||
# One stream, so the left pane is a schedule instead of a station list.
|
||||
has_schedule = True
|
||||
# A shared library rotation is not a Bandcamp catalogue; skip the lookups.
|
||||
bandcamp = False
|
||||
|
||||
@property
|
||||
def user_agent(self) -> str:
|
||||
# A small volunteer station; introduce ourselves and say where we are.
|
||||
return f"{config.USER_AGENT}; +https://tilderadio.org"
|
||||
|
||||
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
|
||||
return [await api.fetch_station(client)]
|
||||
|
||||
def load_cached_stations(self) -> list[Station]:
|
||||
return api.load_cached_station()
|
||||
|
||||
def fallback_stations(self) -> list[Station]:
|
||||
return fallback.fallback_stations()
|
||||
|
||||
async def fetch_history(
|
||||
self, client: httpx.AsyncClient, station_id: str
|
||||
) -> list[Song]:
|
||||
return await api.fetch_songs(client, station_id)
|
||||
|
||||
def load_cached_history(self, station_id: str) -> list[Song]:
|
||||
return api.load_cached_songs(station_id)
|
||||
|
||||
async def fetch_schedule(
|
||||
self, client: httpx.AsyncClient
|
||||
) -> list[Broadcast]:
|
||||
return await api.fetch_schedule(client)
|
||||
|
||||
def load_cached_schedule(self) -> list[Broadcast]:
|
||||
return api.load_cached_schedule()
|
||||
|
||||
def fallback_urls(self, station: Station) -> list[str]:
|
||||
return fallback.known_stream_urls()
|
||||
@@ -0,0 +1,401 @@
|
||||
"""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
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Bundled station, so a cold offline start still makes sense.
|
||||
|
||||
tilderadio is a single-station catalogue — one community stream for the whole
|
||||
tildeverse — so "the bundled station list" is that one station. The mounts
|
||||
below are the ones the AzuraCast instance has served for years; the live list
|
||||
from ``/api/now/`` always wins, and the URLs are additionally probed by the
|
||||
player's normal failover.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# (url, format, quality, bitrate)
|
||||
_MOUNTS: tuple[tuple[str, str, str, str], ...] = (
|
||||
("https://azuracast.tilderadio.org/listen/tilderadio/radio.mp3", "mp3", "high", "192"),
|
||||
("https://azuracast.tilderadio.org/listen/tilderadio/320k.mp3", "mp3", "highest", "320"),
|
||||
("https://azuracast.tilderadio.org/listen/tilderadio/radio.ogg", "ogg", "high", "192"),
|
||||
)
|
||||
|
||||
|
||||
def fallback_payload() -> dict[str, Any]:
|
||||
"""A ``/api/now/``-shaped payload, so the normal parser can eat it."""
|
||||
return {
|
||||
"api_version": 1,
|
||||
"status": "ok",
|
||||
"station": {
|
||||
"id": 1,
|
||||
"name": "tilderadio",
|
||||
"shortcode": "tilderadio",
|
||||
"listen_url": _MOUNTS[2][0],
|
||||
},
|
||||
"is_live": False,
|
||||
"dj": None,
|
||||
"listeners": 0,
|
||||
"now_playing": None,
|
||||
"history": [],
|
||||
}
|
||||
|
||||
|
||||
def fallback_playlists() -> list[dict[str, str]]:
|
||||
return [
|
||||
{"url": url, "format": fmt, "quality": quality, "bitrate": bitrate}
|
||||
for url, fmt, quality, bitrate in _MOUNTS
|
||||
]
|
||||
|
||||
|
||||
def known_stream_urls() -> list[str]:
|
||||
return [url for url, _format, _quality, _bitrate in _MOUNTS]
|
||||
|
||||
|
||||
def fallback_stations() -> list[Any]:
|
||||
from .api import station_from_now
|
||||
|
||||
station = station_from_now(fallback_payload(), fallback_playlists())
|
||||
return [station] if station is not None else []
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Generated header art — do not edit by hand.
|
||||
|
||||
Source: https://tilderadio.org/logos/tilderadio.png
|
||||
|
||||
Regenerate with::
|
||||
|
||||
uv run python tools/ansi_art.py --url https://tilderadio.org/logos/tilderadio.png --cols 22 --rows 7 --module beefy_player/providers/tilderadio/hero.py
|
||||
|
||||
Each row is rich markup; ``▀`` is a half block whose foreground is the top
|
||||
pixel and whose background is the pixel below it. Colours are stock ANSI
|
||||
palette names, so the art keeps the terminal's own palette.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
HERO: tuple[str, ...] = (
|
||||
' [bright_black]▄[/][bright_black on bright_black]▀▀[/][bright_black]▀[/]',
|
||||
' [bright_black]▄▄[/][bright_black on bright_black]▀▀▀[/][bright_black]▄▄▄▄▄▄▄▄▄▄▄[/]',
|
||||
'[bright_black]▄[/][bright_black on bright_black]▀[/][bright_black on black]▀▀▀▀▀▀▀▀▀▀▀[/][bright_black on bright_black]▀▀▀▀▀▀[/][bright_black on black]▀[/][bright_black on bright_black]▀[/][bright_black]▄[/]',
|
||||
'[bright_black on bright_black]▀▀[/][black on black]▀▀▀▀▀▀▀▀▀▀[/][bright_black on bright_black]▀▀[/][bright_black]▀[/] [bright_black on bright_black]▀▀▀▀[/]',
|
||||
'[bright_black on bright_black]▀▀[/][black on black]▀[/][black on bright_black]▀▀[/][black on green]▀[/][black on bright_black]▀▀[/][black on black]▀▀[/][black on bright_black]▀[/][black on black]▀▀[/][bright_black on black]▀[/][bright_black on bright_black]▀[/][bright_black]▄▄[/][bright_black on bright_black]▀▀[/][bright_black on black]▀[/][bright_black on bright_black]▀▀[/]',
|
||||
'[bright_black on bright_black]▀▀[/][black on black]▀[/][green on green]▀[/][green on black]▀▀▀[/][green on green]▀▀▀▀[/][bright_black on black]▀[/][black on black]▀▀▀[/][bright_black on bright_black]▀[/][bright_black on black]▀[/][bright_black on bright_black]▀▀[/][black on black]▀[/][bright_black on bright_black]▀▀[/]',
|
||||
'[bright_black]▀▀[/][bright_black on bright_black]▀[/][black on bright_black]▀▀[/][bright_black on bright_black]▀[/][black on bright_black]▀▀▀▀▀[/][bright_black on bright_black]▀[/][black on bright_black]▀[/][bright_black on bright_black]▀[/][black on bright_black]▀[/][bright_black on bright_black]▀▀▀[/][black on bright_black]▀[/][bright_black on bright_black]▀[/][bright_black]▀▀[/]',
|
||||
)
|
||||
+2
-2
@@ -1,13 +1,13 @@
|
||||
[project]
|
||||
name = "beefy-player"
|
||||
version = "0.1.0"
|
||||
description = "A flashy terminal player for internet radio — live spectrum analyser, MPRIS / macOS Now Playing support, song history and pluggable station catalogues (SomaFM included)"
|
||||
description = "A flashy terminal player for internet radio — live spectrum analyser, MPRIS / macOS Now Playing support, song history and pluggable station catalogues (SomaFM and tilderadio included)"
|
||||
readme = "README.md"
|
||||
license = "WTFPL"
|
||||
license-files = ["LICENSE"]
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "beefy-player contributors" }]
|
||||
keywords = ["radio", "internet-radio", "tui", "terminal", "mpris", "audio", "somafm"]
|
||||
keywords = ["radio", "internet-radio", "tui", "terminal", "mpris", "audio", "somafm", "tilderadio"]
|
||||
classifiers = [
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""tilderadio: parsing, caching and the offline fallback.
|
||||
|
||||
The payloads below are trimmed copies of real ``/api/now/`` and AzuraCast
|
||||
``/api/nowplaying/<station>`` responses, so the tests keep working when the
|
||||
station is off air.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from beefy_player.providers.tilderadio import TilderadioProvider, api, fallback
|
||||
|
||||
NOW_PAYLOAD = {
|
||||
"api_version": 1,
|
||||
"status": "ok",
|
||||
"updated_at": 1793741211,
|
||||
"available": True,
|
||||
"station": {
|
||||
"id": 1,
|
||||
"name": "tilderadio",
|
||||
"shortcode": "tilderadio",
|
||||
"listen_url": "https://azuracast.tilderadio.org/listen/tilderadio/radio.ogg",
|
||||
},
|
||||
"is_live": False,
|
||||
"broadcast_start": None,
|
||||
"dj": None,
|
||||
"listeners": 5,
|
||||
"now_playing": {
|
||||
"artist": None,
|
||||
"title": "souldies-dramatics-feel it",
|
||||
"text": "souldies-dramatics-feel it",
|
||||
"art": "https://azuracast.tilderadio.org/api/station/tilderadio/art/199f03",
|
||||
"played_at": 1793741079,
|
||||
"duration": 192,
|
||||
},
|
||||
"history": [
|
||||
{
|
||||
"artist": None,
|
||||
"title": "souldies-everytime i see a pretty lady-gemini",
|
||||
"text": "souldies-everytime i see a pretty lady-gemini",
|
||||
"art": "https://azuracast.tilderadio.org/api/station/tilderadio/art/5e46a9",
|
||||
"played_at": 1793740639,
|
||||
},
|
||||
{
|
||||
"artist": "TILDE PROMO (station)",
|
||||
"title": "owly (bots 3)",
|
||||
"text": "TILDE PROMO (station) - owly (bots 3)",
|
||||
"art": "https://azuracast.tilderadio.org/api/station/tilderadio/art/54ac9f",
|
||||
"played_at": 1793740629,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
MOUNTS_PAYLOAD = {
|
||||
"station": {
|
||||
"id": 1,
|
||||
"name": "tilderadio",
|
||||
"shortcode": "tilderadio",
|
||||
"listen_url": "https://azuracast.tilderadio.org/listen/tilderadio/radio.ogg",
|
||||
"mounts": [
|
||||
{
|
||||
"name": "/radio.mp3 (192kbps MP3)",
|
||||
"url": "https://azuracast.tilderadio.org/listen/tilderadio/radio.mp3",
|
||||
"bitrate": 192,
|
||||
"format": "mp3",
|
||||
"is_default": False,
|
||||
},
|
||||
{
|
||||
"name": "/320k.mp3 (320kbps MP3)",
|
||||
"url": "https://azuracast.tilderadio.org/listen/tilderadio/320k.mp3",
|
||||
"bitrate": 320,
|
||||
"format": "mp3",
|
||||
"is_default": False,
|
||||
},
|
||||
{
|
||||
"name": "192kbps OGG Vorbis",
|
||||
"url": "https://azuracast.tilderadio.org/listen/tilderadio/radio.ogg",
|
||||
"bitrate": 192,
|
||||
"format": "ogg",
|
||||
"is_default": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def client_for(*responses: tuple[str, int, object]) -> httpx.AsyncClient:
|
||||
"""An httpx client that answers from ``(url_prefix, status, payload)``."""
|
||||
table = list(responses)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
for prefix, status, payload in table:
|
||||
if str(request.url).startswith(prefix):
|
||||
return httpx.Response(status, json=payload)
|
||||
return httpx.Response(404, json={})
|
||||
|
||||
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
def test_provider_describes_its_catalogue() -> None:
|
||||
provider = TilderadioProvider()
|
||||
assert provider.id == "tilderadio"
|
||||
assert provider.brand == "TILDE RADIO"
|
||||
assert provider.station_source == "tilderadio.org/api/now/"
|
||||
assert "tilderadio.org" in provider.user_agent
|
||||
|
||||
stations = provider.fallback_stations()
|
||||
assert len(stations) == 1
|
||||
station = stations[0]
|
||||
assert station.uid == "tilderadio:tilderadio"
|
||||
assert all("tilderadio.org" in url for url in provider.fallback_urls(station))
|
||||
# the bundled list still advertises a real bitrate, not a URL guess
|
||||
assert station.format_label == "mp3 · 320k"
|
||||
|
||||
|
||||
def test_station_from_now_reads_the_single_station() -> None:
|
||||
station = api.station_from_now(NOW_PAYLOAD, fallback.fallback_playlists())
|
||||
assert station is not None
|
||||
assert station.id == "tilderadio"
|
||||
assert station.provider == "tilderadio"
|
||||
assert station.listeners == 5
|
||||
assert station.dj == "" # nobody is on air
|
||||
assert station.genre_label == "community · eclectic"
|
||||
assert station.now_playing is not None
|
||||
assert station.now_playing.title == "souldies-dramatics-feel it"
|
||||
assert station.now_playing.art_url.endswith("199f03")
|
||||
assert station.last_playing == "souldies-dramatics-feel it"
|
||||
|
||||
|
||||
def test_live_dj_is_only_named_when_someone_is_on_air() -> None:
|
||||
live = dict(NOW_PAYLOAD, is_live=True, dj="ffog")
|
||||
station = api.station_from_now(live)
|
||||
assert station is not None and station.dj == "ffog"
|
||||
# the AzuraCast flavour of the same field wins when present
|
||||
nested = dict(NOW_PAYLOAD, live={"is_live": True, "name": "controlfreak"})
|
||||
station = api.station_from_now(nested)
|
||||
assert station is not None and station.dj == "controlfreak"
|
||||
|
||||
|
||||
def test_artist_is_split_out_of_the_text_blob() -> None:
|
||||
song = api.song_from_track(
|
||||
{
|
||||
"artist": "TILDE PROMO (station)",
|
||||
"title": "owly (bots 3)",
|
||||
"text": "TILDE PROMO (station) - owly (bots 3)",
|
||||
"played_at": 1793740629,
|
||||
}
|
||||
)
|
||||
assert song is not None
|
||||
assert (song.artist, song.title) == ("TILDE PROMO (station)", "owly (bots 3)")
|
||||
|
||||
# no artist field: the "Artist - Title" text has to carry it
|
||||
song = api.song_from_track(
|
||||
{"title": "Kenny Ken", "text": "Kenny Ken - Amazon Jungle Collection 3"}
|
||||
)
|
||||
assert song is not None
|
||||
assert (song.artist, song.title) == ("Kenny Ken", "Amazon Jungle Collection 3")
|
||||
|
||||
|
||||
def test_songs_from_now_puts_the_current_track_first_and_dedupes() -> None:
|
||||
payload = dict(NOW_PAYLOAD)
|
||||
# a feed that repeats the current track in its own history
|
||||
payload["history"] = [NOW_PAYLOAD["now_playing"], *NOW_PAYLOAD["history"]]
|
||||
songs = api.songs_from_now(payload)
|
||||
assert len(songs) == 3
|
||||
assert songs[0].title == "souldies-dramatics-feel it"
|
||||
assert songs[1].title == "souldies-everytime i see a pretty lady-gemini"
|
||||
played = [song.played_at for song in songs]
|
||||
assert played == sorted(played, reverse=True)
|
||||
|
||||
|
||||
def test_mounts_become_playlists_best_first() -> None:
|
||||
playlists = api.playlists_from_mounts(MOUNTS_PAYLOAD)
|
||||
assert [entry["bitrate"] for entry in playlists] == ["192", "320", "192"]
|
||||
station = api.station_from_now(
|
||||
{**NOW_PAYLOAD, "now_playing": None}, playlists
|
||||
)
|
||||
assert station is not None
|
||||
assert station.format_label == "mp3 · 320k"
|
||||
assert station.sorted_playlists()[0]["url"].endswith("320k.mp3")
|
||||
assert [entry["quality"] for entry in playlists] == ["high", "highest", "high"]
|
||||
|
||||
|
||||
def test_station_without_mounts_falls_back_to_the_listen_url() -> None:
|
||||
station = api.station_from_now(NOW_PAYLOAD)
|
||||
assert station is not None
|
||||
assert len(station.playlists) == 1
|
||||
assert station.playlists[0]["url"].endswith("radio.ogg")
|
||||
assert station.format_label == "ogg · 128k" # guessed, the feed didn't say
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_station_caches_what_it_learned() -> None:
|
||||
async with client_for(
|
||||
("https://tilderadio.org/api/now/", 200, NOW_PAYLOAD),
|
||||
("https://azuracast.tilderadio.org/api/nowplaying/", 200, MOUNTS_PAYLOAD),
|
||||
) as client:
|
||||
station = await api.fetch_station(client)
|
||||
assert station.listeners == 5
|
||||
assert station.format_label == "mp3 · 320k"
|
||||
|
||||
cached = api.load_cached_station()
|
||||
assert len(cached) == 1
|
||||
assert cached[0].id == "tilderadio"
|
||||
assert cached[0].now_playing is not None
|
||||
assert cached[0].format_label == "mp3 · 320k"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_station_survives_an_unreachable_azuracast() -> None:
|
||||
async with client_for(("https://tilderadio.org/api/now/", 200, NOW_PAYLOAD)) as client:
|
||||
station = await api.fetch_station(client)
|
||||
# no mounts -> the documented listen_url is still a usable stream
|
||||
assert station.playlists[0]["url"].endswith("radio.ogg")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_falls_back_to_the_cache_when_the_feed_is_down() -> None:
|
||||
async with client_for(
|
||||
("https://tilderadio.org/api/now/", 200, NOW_PAYLOAD)
|
||||
) as client:
|
||||
songs = await api.fetch_songs(client, "tilderadio")
|
||||
assert len(songs) == 3
|
||||
|
||||
async with client_for(
|
||||
("https://tilderadio.org/api/now/", 500, {})
|
||||
) as client:
|
||||
cached = await api.fetch_songs(client, "tilderadio")
|
||||
assert [song.title for song in cached] == [song.title for song in songs]
|
||||
|
||||
|
||||
def test_station_cache_is_per_provider() -> None:
|
||||
from beefy_player import config
|
||||
|
||||
assert config.channels_cache_path("tilderadio").name == "channels-tilderadio.json"
|
||||
# SomaFM keeps its historical file name, so upgrades don't lose the cache
|
||||
assert config.channels_cache_path("somafm") == config.CHANNELS_CACHE
|
||||
|
||||
|
||||
def test_fallback_station_is_usable_offline() -> None:
|
||||
provider = TilderadioProvider()
|
||||
stations = provider.fallback_stations()
|
||||
assert [station.id for station in stations] == ["tilderadio"]
|
||||
station = stations[0]
|
||||
assert station.listeners == 0
|
||||
assert station.now_playing is None
|
||||
assert {entry["format"] for entry in station.playlists} == {"mp3", "ogg"}
|
||||
Reference in new issue
Block a user